feat: 添加终端高亮系统与配置管理优化

- 新增终端高亮系统,支持8类规则自动识别关键信息
- 使用正则表达式合并算法,终端所有的文本块输出只需要1-2次正则匹配即可,不影响实时的输出速度
- 高亮设置支持实时预览和规则导入导出
- 重构终端配置管理,提供模块化的终端设置界面,分为三部分:基础设置、高亮设置和其他设置,其中基础设置包含原来的的本地设置/终端设置内容,其他设置包含原来的本地设置/快捷操作内容,高亮设置为新增
- 新增TerminalConfigDB类,所有的终端设置使用数据库持久化管理
- 修复终端主题定制中的半透明灰色图层遮盖问题,保证终端背景颜色和效果颜色一致性,支持各种终端内容的深度定制,包括字体、字体大小、字体颜色、光标颜色、选中颜色等,新增了更多预设背景色
- 脚本库中新增执行模式选项,用户可以选择使用直接运行或者base64编码处理后运行,以支持复杂的多行脚本运行问题和灵活管理
- 增强sftp组件,支持sftp窗口宽度拖拽改变,个性化显示文件信息列(大小、修改时间、权限、拥有者)
This commit is contained in:
zhanghao-njmu
2025-10-11 09:12:48 +08:00
parent e37c6a8959
commit 152f18e07c
21 changed files with 14495 additions and 265 deletions
+11088
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@ module.exports = {
favoriteSftpDBPath: path.join(process.cwd(),'app/db/favorite-sftp.db'),
proxyDBPath: path.join(process.cwd(),'app/db/proxy.db'),
fileTransferDBPath: path.join(process.cwd(),'app/db/file-transfer.db'),
terminalConfigDBPath: path.join(process.cwd(),'app/db/terminal-config.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/db/logs'),
+6 -4
View File
@@ -24,19 +24,21 @@ async function getLocalScriptList({ res }) {
}
const addScript = async ({ res, request }) => {
let { body: { name, description, command, index, group } } = request
// useBase64 默认为false
let { body: { name, description, command, index, group, useBase64 = false } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
index = Number(index) || 0
let record = { name, description, command, index, group }
let record = { name, description, command, index, group, useBase64 }
await scriptsDB.insertAsync(record)
res.success({ data: '添加成功' })
}
const updateScriptList = async ({ res, request }) => {
let { params: { id } } = request
let { body: { name, description, command, index, group } } = request
// useBase64 默认为false
let { body: { name, description, command, index, group, useBase64 = false } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
await scriptsDB.updateAsync({ _id: id }, { name, description, command, index, group })
await scriptsDB.updateAsync({ _id: id }, { name, description, command, index, group, useBase64 })
res.success({ data: '修改成功' })
}
+95
View File
@@ -0,0 +1,95 @@
const { TerminalConfigDB } = require('../utils/db-class')
// 终端默认配置
const DEFAULT_TERMINAL_CONFIG = {
themeName: 'Afterglow',
fontFamily: 'monospace', // 使用多平台通用的等宽字体
fontSize: 16,
fontColor: '', // 使用主题字体颜色
cursorColor: '', // 使用主题光标颜色
selectionColor: '#264f78', // 深蓝色选中颜色,适合深色主题
background: '', // 使用主题背景
keywordHighlight: true,
highlightDebugMode: false,
customHighlightRules: null, // null表示使用默认规则
autoExecuteScript: false, // 脚本执行模式
autoReconnect: false // 自动重连
}
const terminalConfigDB = new TerminalConfigDB().getInstance()
// 获取用户的终端配置
async function getTerminalConfig(ctx) {
const { uid } = ctx.request.query
if (!uid || typeof uid !== 'string' || uid.trim() === '') {
ctx.body = { success: false, message: '无效的uid参数' }
return
}
try {
// 查找用户配置
let userConfig = await terminalConfigDB.findOneAsync({ uid, isDefault: { $ne: true } })
if (!userConfig) {
// 如果没有用户配置,直接返回默认配置
userConfig = {
...DEFAULT_TERMINAL_CONFIG,
uid
}
} else {
// 合并默认配置和用户配置
userConfig = {
...DEFAULT_TERMINAL_CONFIG,
...userConfig // 用户配置覆盖默认配置
}
}
ctx.body = { success: true, data: userConfig }
} catch (error) {
console.error('Error in getTerminalConfig:', error)
ctx.body = { success: false, message: '服务器内部错误,请稍后重试' }
}
}
// 保存用户的终端配置
async function saveTerminalConfig(ctx) {
const { uid, isDefault, name, _id, createTime, ...configData } = ctx.request.body
if (!uid || typeof uid !== 'string' || uid.trim() === '') {
ctx.body = { success: false, message: '无效的uid参数' }
return
}
try {
const existingConfig = await terminalConfigDB.findOneAsync({ uid, isDefault: { $ne: true } })
const updateData = {
...configData,
uid,
updateTime: new Date().toISOString()
}
if (existingConfig) {
// 更新现有配置
await terminalConfigDB.updateAsync(
{ _id: existingConfig._id },
{ $set: updateData }
)
} else {
// 创建新配置
updateData.createTime = new Date().toISOString()
await terminalConfigDB.insertAsync(updateData)
}
ctx.body = { success: true, message: '配置保存成功' }
} catch (error) {
console.error('Error in saveTerminalConfig:', error)
ctx.body = { success: false, message: '服务器内部错误,请稍后重试' }
}
}
module.exports = {
getTerminalConfig,
saveTerminalConfig
}
+16 -1
View File
@@ -9,6 +9,7 @@ const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
const { getLog, saveIpWhiteList, removeSomeLoginRecords } = require('../controller/log')
const { getAIConfig, saveAIConfig, getAIModels, getChatHistory, saveChatHistory, removeChatHistory } = require('../controller/chat')
const { getProxyList, addProxy, updateProxy, removeProxy } = require('../controller/proxy')
const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config')
const ssh = [
{
@@ -330,6 +331,19 @@ const proxy = [
}
]
const terminalConfig = [
{
method: 'get',
path: '/terminal-config',
controller: getTerminalConfig
},
{
method: 'post',
path: '/terminal-config',
controller: saveTerminalConfig
}
]
module.exports = [].concat(
ssh,
host,
@@ -341,5 +355,6 @@ module.exports = [].concat(
onekey,
log,
aiConfig,
proxy
proxy,
terminalConfig
)
+70 -11
View File
@@ -11,6 +11,52 @@ const hostListDB = new HostListDB().getInstance()
const favoriteSftpDB = new FavoriteSftpDB().getInstance()
const { Client: SSHClient } = require('ssh2')
/**
* https://github.com/theophilusx/ssh2-sftp-client#readme
* The objects in the array returned by list() have the following properties:
* {
* type: '-', // file type(-, d, l)
* name: 'example.txt', // file name
* size: 43, // file size
* modifyTime: 1675645360000, // file timestamp of modified time
* accessTime: 1675645360000, // file timestamp of access time
* rights: {
* user: 'rw',
* group: 'r',
* other: 'r',
* },
* owner: 1000, // user ID
* group: 1000, // group ID
* longname: '-rw-r--r-- 1 fred fred 43 Feb 6 12:02 exaple.txt', // like ls -l line
* }
*/
// 格式化文件列表数据,添加权限字符串和用户名/组名
function formatFileList(fileList) {
return fileList.map(file => {
const formatted = { ...file }
// 从 rights 对象生成权限字符串
if (file.rights) {
const { user, group, other } = file.rights
formatted.permissions = `${user}${group}${other}`
}
// 从 longname 中提取ownerName和groupName
if (file.longname) {
const parts = file.longname.trim().split(/\s+/)
if (parts.length >= 4) {
// parts[2] 是所有者用户名, parts[3] 是组名
formatted.ownerName = parts[2]
formatted.groupName = parts[3]
}
}
return formatted
})
}
const listenAction = (sftpClient, socket) => {
// 下载任务管理
const downloadTasks = new Map() // taskId -> { abortController, startTime, totalSize, downloadedSize }
@@ -23,7 +69,8 @@ const listenAction = (sftpClient, socket) => {
try {
const dirLs = await sftpClient.list(path)
console.log('dirLs:', dirLs.length)
socket.emit('dir_ls', dirLs, path)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, path)
} catch (err) {
socket.emit('not_exists_dir', err.message)
}
@@ -39,7 +86,8 @@ const listenAction = (sftpClient, socket) => {
socket.emit('rename_success', { oldName, newName })
// 返回最新目录列表
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
socket.emit('rename_fail', err.message)
}
@@ -56,7 +104,8 @@ const listenAction = (sftpClient, socket) => {
}
socket.emit('delete_success')
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
socket.emit('delete_fail', err.message)
}
@@ -74,7 +123,8 @@ const listenAction = (sftpClient, socket) => {
}
socket.emit('delete_success')
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
socket.emit('delete_fail', err.message)
}
@@ -89,7 +139,8 @@ const listenAction = (sftpClient, socket) => {
await sftpClient.rename(src, dest)
socket.emit('move_success')
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
socket.emit('move_fail', err.message)
}
@@ -105,7 +156,8 @@ const listenAction = (sftpClient, socket) => {
}
socket.emit('move_success')
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
socket.emit('move_fail', err.message)
}
@@ -250,7 +302,8 @@ const listenAction = (sftpClient, socket) => {
socket.emit('copy_success')
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
consola.error('copy error:', err.message)
socket.emit('copy_fail', err.message)
@@ -296,7 +349,8 @@ const listenAction = (sftpClient, socket) => {
// 返回最新目录列表
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
consola.error('create error:', err.message)
socket.emit('create_fail', err.message)
@@ -344,7 +398,8 @@ const listenAction = (sftpClient, socket) => {
// 返回最新目录列表
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
consola.error('compress error:', err.message)
socket.emit('compress_fail', err.message)
@@ -426,7 +481,8 @@ const listenAction = (sftpClient, socket) => {
// 返回最新目录列表
const dirLs = await sftpClient.list(dirPath)
socket.emit('dir_ls', dirLs, dirPath)
const formattedDirLs = formatFileList(dirLs)
socket.emit('dir_ls', formattedDirLs, dirPath)
} catch (err) {
consola.error('decompress error:', err.message)
socket.emit('decompress_fail', err.message)
@@ -1462,9 +1518,12 @@ module.exports = (httpServer) => {
}
}
// 格式化文件列表
const formattedRootList = formatFileList(rootList)
// 普通文件-、目录文件d、链接文件l
socket.emit('connect_success', {
rootList,
rootList: formattedRootList,
isRootUser,
currentPath: currentWorkingDir
})
+24 -1
View File
@@ -15,7 +15,8 @@ const {
chatHistoryDBPath,
favoriteSftpDBPath,
proxyDBPath,
fileTransferDBPath
fileTransferDBPath,
terminalConfigDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
@@ -201,4 +202,26 @@ module.exports.FileTransferDB = class FileTransferDB {
getInstance() {
return FileTransferDB.instance
}
}
module.exports.HighlightConfigDB = class HighlightConfigDB {
constructor() {
if (!HighlightConfigDB.instance) {
HighlightConfigDB.instance = new Datastore({ filename: terminalConfigDBPath, autoload: true })
}
}
getInstance() {
return HighlightConfigDB.instance
}
}
module.exports.TerminalConfigDB = class TerminalConfigDB {
constructor() {
if (!TerminalConfigDB.instance) {
TerminalConfigDB.instance = new Datastore({ filename: terminalConfigDBPath, autoload: true })
}
}
getInstance() {
return TerminalConfigDB.instance
}
}
+1
View File
@@ -27,6 +27,7 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"fs-extra": "^11.2.0",
"get-intrinsic": "^1.3.0",
"global": "^4.4.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
+7
View File
@@ -180,5 +180,12 @@ export default {
},
removeProxy(id) {
return axios({ url: `/proxy/${ id }`, method: 'delete' })
},
// 终端配置相关API
getTerminalConfig(params = {}) {
return axios({ url: '/terminal-config', method: 'get', params })
},
saveTerminalConfig(data) {
return axios({ url: '/terminal-config', method: 'post', data })
}
}
+54 -15
View File
@@ -30,17 +30,27 @@ const useStore = defineStore('global', {
'linear-gradient(-20deg, #2b5876 0%, #4e4376 100%)',
'linear-gradient(to top, #1e3c72 0%, #1e3c72 1%, #2a5298 100%)',
'linear-gradient(to right, #243949 0%, #517fa4 100%)',
// 深色背景
'linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)', // 深蓝紫
'linear-gradient(to right, #1a1a2e 0%, #16213e 100%)', // 深夜蓝
'linear-gradient(to right, #0f2027 0%, #203a43 50%, #2c5364 100%)', // 深青蓝
'linear-gradient(135deg, #141e30 0%, #243b55 100%)', // 深靛蓝
'linear-gradient(to bottom, #000000 0%, #2c2c2c 100%)', // 纯黑到灰
'linear-gradient(135deg, #2c1810 0%, #3d2817 100%)', // 深褐
'linear-gradient(to bottom, #1e3a2e 0%, #0f2922 100%)', // 深绿
'linear-gradient(to right, #2b1b3d 0%, #3a2449 100%)', // 深紫
// 浅色背景
'linear-gradient(120deg, #ffffff 0%, #f5f5f5 100%)', // 纯白
'linear-gradient(120deg, #faf8f3 0%, #f0ebe1 100%)', // 柔和浅米白
'linear-gradient(120deg, #d4e4f7 0%, #b5d3e7 100%)', // 柔和浅蓝
'linear-gradient(to top, #e8dff5 0%, #d5c6e0 100%)', // 柔和浅紫
'linear-gradient(135deg, #e8d5c4 0%, #d4c4b0 100%)', // 柔和浅褐
'linear-gradient(120deg, #d5e5d5 0%, #c8dcc8 100%)', // 柔和浅绿
],
terminalConfig: {
...{
fontSize: 16,
themeName: 'Afterglow',
background: 'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
autoReconnect: true,
autoExecuteScript: false
},
...(localStorage.getItem('terminalConfig') ? JSON.parse(localStorage.getItem('terminalConfig')) : {})
},
// 终端配置占位
terminalConfig: {},
menuSetting: {
...{
scriptLibrary: true,
@@ -84,6 +94,7 @@ const useStore = defineStore('global', {
await this.getScriptGroupList()
await this.getPlusInfo()
await this.getProxyList()
await this.getTerminalConfig() // 添加终端配置获取
this.getAIConfig()
this.getChatHistory()
},
@@ -135,7 +146,7 @@ const useStore = defineStore('global', {
const isPlusActive = new Date(plusInfo.expiryDate) > new Date()
this.$patch({ isPlusActive })
if (!isPlusActive) {
this.setTerminalSetting({ autoReconnect: false })
localStorage.setItem('autoReconnect', 'false')
return
}
plusInfo.expiryDate = dayjs(plusInfo.expiryDate).format('YYYY-MM-DD')
@@ -146,10 +157,38 @@ const useStore = defineStore('global', {
}
this.$patch({ plusInfo })
},
setTerminalSetting(setTarget = {}) {
let newConfig = { ...this.terminalConfig, ...setTarget }
localStorage.setItem('terminalConfig', JSON.stringify(newConfig))
this.$patch({ terminalConfig: newConfig })
async getTerminalConfig() {
try {
const { data: terminalConfig } = await $api.getTerminalConfig({ uid: this.uid })
this.$patch({ terminalConfig })
} catch (error) {
console.error('获取终端配置失败:', error)
// Fallback
// 默认配置信息位于: server/app/controller/terminal-config.js
this.$patch({ terminalConfig: {
themeName: 'Afterglow',
fontFamily: 'monospace',
fontSize: 16,
fontColor: '',
cursorColor: '',
selectionColor: '#264f78',
background: '',
keywordHighlight: true,
highlightDebugMode: false,
customHighlightRules: null,
autoExecuteScript: false,
autoReconnect: false
} })
}
},
async setTerminalSetting(setTarget = {}) {
const newConfig = { ...this.terminalConfig, ...setTarget }
try {
await $api.saveTerminalConfig({ ...newConfig, uid: this.uid })
this.$patch({ terminalConfig: newConfig })
} catch (error) {
console.error('保存终端配置失败:', error)
}
},
setMenuSetting(setTarget = {}) {
let newConfig = { ...this.menuSetting, ...setTarget }
+579
View File
@@ -0,0 +1,579 @@
/**
* 终端高亮器和默认的规则定义
*/
// ANSI终端文字样式
export const TEXT_STYLES = {
BOLD: '\x1b[1m', // 粗体/高亮
ITALIC: '\x1b[3m', // 斜体
UNDERLINE: '\x1b[4m', // 下划线
RESET: '\x1b[0m' // 重置样式
}
// 默认颜色映射
export const DEFAULT_COLOR_MAPPING = {
rule1: '#ff4d4f', // 错误
rule2: '#fadb14', // 警告
rule3: '#52c41a', // 成功
rule4: '#13c2c2', // 信息
rule5: '#eb2f96', // 网络
rule6: '#1890ff', // 链接
rule7: '#ffffff', // 日期时间
rule8: '#8b5cf6' // 单位数据
}
// 按颜色分组的高亮规则
export const HIGHLIGHT_RULES = {
// 规则1 - 错误类关键词
rule1: {
title: '错误类关键词',
pattern: /\b(?:errors?|err|fail(?:ed|ure)?|fatal|critical|denied|refused|broken|crash(?:ed)?|exception|timeout|abort(?:ed)?|reject(?:ed)?|forbidden|unauthorized|conflict|corrupt(?:ed)?|missing|not found|unreachable|disconnect(?:ed)?|kill(?:ed)?|terminate(?:d)?|dead|died|panic|alarm|alert|emergency|severe|cannot|unable|impossible|blocked|locked|disaster|malformed|malicious|virus|breach|hack(?:ed)?|attack|exploit|vulnerability|damaged|destroyed|overload|overflow|outage|down|offline|inaccessible|unavailable|suspended|revoked|expired|expires|blacklisted|infected|compromised|hijacked|suspicious|illegal|loss|death|bad)\b/,
flags: 'gi',
fullLine: true,
displayColor: DEFAULT_COLOR_MAPPING.rule1,
backgroundColor: null,
bold: true,
italic: false,
underline: false,
enabled: true
},
// 规则2 - 警告类关键词
rule2: {
title: '警告类关键词',
pattern: /\b(?:warn(?:ing)?s?|deprecated|caution|retry|retrying|retried|skipped|ignored|pause(?:d)?|delay(?:ed)?|slow|slower|outdated|obsolete|insecure|vulnerable|risky|unstable|experimental|beta|alpha|preview|temporary|temp|pending|throttle(?:d)?|restrict(?:ed)?|downgrade(?:d)?|fallback|backup|migration|maintenance|partial|limited|degraded|reduced|minor|notice|advisory|reminder|important|security|urgent|attention|required|mandatory|danger|risk|permission)\b/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule2,
backgroundColor: null,
bold: false,
italic: true,
underline: false,
enabled: true
},
// 规则3 - 成功类关键词
rule3: {
title: '成功类关键词',
pattern: /\b(?:success(?:ful)?|successfully|complete(?:d)?|completed|finish(?:ed)?|finished|ok(?:ay)?|ready|active|running|begin|launch(?:ed)?|launched|connect(?:ed)?|connected|online|available|enabled|valid|verified|confirmed|approved|passed|accepted|resolved|fixed|repaired|restored|recovered|upgraded|updated|installed|deployed|built|compiled|loaded|mounted|synchronized|synced|healthy|stable|secure|safe|protected|authenticated|authorized|granted|allowed|permitted|working|alive|opened|succeeded|established)\b/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule3,
backgroundColor: null,
bold: false,
italic: false,
underline: false,
enabled: true
},
// 规则4 - 信息类关键词
rule4: {
title: '信息类关键词',
pattern: /\b(?:info|information|notification|message|msg|debug|trace|verbose|status|report|summary|loading|connecting|processing|monitoring|checking|scanning|analyzing|parsing|building|compiling|initializing|setup|preparing|progress|executing|stopped|stopping|resumed|resuming|restarted|restarting|closed|queued|removed|sleeping|zombie)\b/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule4,
backgroundColor: null,
bold: false,
italic: false,
underline: false,
enabled: true
},
// 规则5 - 网络地址
rule5: {
title: 'IP地址和端口',
pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?::[1-9]\d{0,4})?\b|(?:(?:[0-9a-fA-F]{1,4}:)*)?::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{0,4}|(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule5,
backgroundColor: null,
bold: false,
italic: false,
underline: false,
enabled: true
},
// 规则6 - URL链接、邮箱地址、文件路径
rule6: {
title: 'URL链接和路径',
pattern: /(?:https?|ftp|ftps|ssh|telnet|ws|wss):\/\/[^\s]+|file:\/\/[^\s]+|mailto:[^\s]+|www\.[^\s]+\.[a-z]{2,}[^\s]*|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|(?<=^|\s)(?:[/~]|\.\.?\/)[a-zA-Z0-9_\-./]+|(?<=^|\s)[A-Z]:\\[a-zA-Z0-9_\-.\\\s]+/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule6,
backgroundColor: null,
bold: false,
italic: false,
underline: true,
enabled: true
},
// 规则7 - 日期时间格式
rule7: {
title: '日期时间',
pattern: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[Tt\s]\d{1,2}:\d{1,2}(?::\d{1,2})?(?:\.\d+)?[Zz]?)?\b|\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b|\b(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?:\.\d+)?(?:\s?[AaPp][Mm])?\b|\[\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}(?:\.\d+)?\]|\b\d{10,13}\b/,
flags: 'gi',
fullLine: false,
displayColor: '#ffffff',
backgroundColor: '#fa541c',
bold: false,
italic: false,
underline: false,
enabled: true
},
// 规则8 - 带单位的数据
rule8: {
title: '数据和单位',
pattern: /\b\d+(?:\.\d+)?\s*(?:TiB|GiB|MiB|KiB|TB|GB|MB|KB|Tbps|Gbps|Mbps|Kbps|bps|ns|μs|ms|min|hrs?|°C|°F|Hz|KHz|MHz|GHz|THz|mV|kV|mA|kA|mW|kW|MW|GW|fps|rpm|RPM|dpi|ppi|px|bits?|bytes?|cores?|threads?)\b|\b(?:\d+(?:\.\d+)?|100(?:\.0+)?)\s*%|\b\d+(?:\.\d+)?\s*(?:ops[/]s|req[/]s|qps|tps|rps|iops|IOPS|pps|PPS)\b|\b\d+(?:\.\d+)?\s+(?:milliseconds?|seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b/,
flags: 'gi',
fullLine: false,
displayColor: DEFAULT_COLOR_MAPPING.rule8,
backgroundColor: null,
bold: false,
italic: false,
underline: false,
enabled: true
}
}
export class TerminalHighlighter {
// ANSI序列正则表达式
// eslint-disable-next-line no-control-regex
static ANSI_DETECT = /\x1b\[/
// eslint-disable-next-line no-control-regex
static ANSI_FULL = /\x1b\[[0-9;]*[mGKH]|\x1b\[[?0-9;]*[lh]|\x1b\[[0-9]*[ABCD]|\x1b\[2J|\x1b\[H|\x1b\[K|\x1b>|\x1b\[7m|\x1b\[27m/g
constructor(terminal, options = {}) {
this.terminal = terminal
this.enabled = options.enabled !== false
this.debugMode = options.debugMode || false
// 自定义规则
this.customRules = options.customRules || null
// 编译规则(按flags分组的合并正则)
const compiled = this.compileRules()
this.mergedPatterns = compiled.mergedPatterns
if (this.debugMode) {
console.log('已初始化终端高亮器')
const totalRules = this.mergedPatterns.reduce((sum, group) =>
sum + Object.keys(group.ruleMap).length, 0)
console.log('已启用规则数:', totalRules)
}
}
// 预编译合并的规则 - 按flags分组
compileRules() {
const rulesToUse = this.customRules || HIGHLIGHT_RULES
// 按 flags 分组
const rulesByFlags = {}
for (const [name, rule] of Object.entries(rulesToUse)) {
// 跳过禁用的规则
if (rule.enabled === false) {
continue
}
// 使用通用方法处理pattern
const patternSource = this.extractPatternSource(rule)
if (!patternSource) {
console.warn(`跳过无效的规则 ${ name }:`, rule.pattern)
continue
}
const flags = rule.flags || 'gi'
if (!rulesByFlags[flags]) {
rulesByFlags[flags] = {
patterns: [],
rules: []
}
}
rulesByFlags[flags].patterns.push(`(${ patternSource })`)
rulesByFlags[flags].rules.push({
name,
displayColor: rule.displayColor,
backgroundColor: rule.backgroundColor,
bold: rule.bold,
italic: rule.italic,
underline: rule.underline
})
}
// 为每组flags创建合并的正则
const mergedPatterns = []
for (const [flags, group] of Object.entries(rulesByFlags)) {
if (group.patterns.length === 0) continue
const mergedPatternSource = group.patterns.join('|')
const mergedPattern = new RegExp(mergedPatternSource, flags)
// 创建规则映射(捕获组索引 -> 规则)
const ruleMap = {}
group.rules.forEach((rule, index) => {
ruleMap[index + 1] = rule
})
mergedPatterns.push({
pattern: mergedPattern,
ruleMap,
flags
})
}
if (this.debugMode) {
console.log('合并正则分组数:', mergedPatterns.length)
mergedPatterns.forEach(group => {
console.log(` flags=${ group.flags }, 规则数=${ Object.keys(group.ruleMap).length }`)
})
}
return { mergedPatterns }
}
// 提取并处理规则的pattern源码
extractPatternSource(rule) {
let patternSource = ''
if (rule.pattern instanceof RegExp) {
patternSource = rule.pattern.source
} else if (rule.pattern && rule.pattern.source) {
patternSource = rule.pattern.source
} else {
return null
}
// 如果启用了fullLine,扩展模式到行尾
if (rule.fullLine) {
patternSource = `(?:${ patternSource }).*?(?=\\r?\\n|$)`
}
return patternSource
}
// 更新自定义规则
updateCustomRules(customRules) {
this.customRules = customRules
// 重新编译规则
const compiled = this.compileRules()
this.mergedPatterns = compiled.mergedPatterns
if (this.debugMode) {
const totalRules = this.mergedPatterns.reduce((sum, group) =>
sum + Object.keys(group.ruleMap).length, 0)
console.log('规则已更新,启用规则数:', totalRules)
}
}
// 主要的高亮方法
highlightText(text) {
if (!this.enabled || !text) {
return text
}
// 跳过空白、极短文本、纯符号文本
if (this.shouldSkip(text)) {
if (this.debugMode) {
console.log('跳过特殊文本:', JSON.stringify(text))
}
return text
}
// 检查是否包含ANSI序列
if (this.hasAnsiSequences(text)) {
// 如果文本主要是控制序列(如光标移动、清屏等),则跳过
if (this.isControlSequenceOnly(text)) {
if (this.debugMode) {
console.log('跳过控制序列文本:', text.replace(/\x1b/g, '\\x1b'))
}
return text
}
// 对于包含少量ANSI但有实际内容的文本,尝试着去处理
try {
const result = this.applyRulesWithAnsi(text)
if (this.debugMode && result !== text) {
console.log('原始文本 (含ANSI):', text.replace(/\x1b/g, '\\x1b'))
console.log('高亮后:', result.replace(/\x1b/g, '\\x1b'))
}
return result
} catch (error) {
if (this.debugMode) {
console.log('ANSI文本处理失败,保持原样:', text.replace(/\x1b/g, '\\x1b'))
}
return text
}
}
try {
const highlightedText = this.applyRules(text)
if (this.debugMode && text !== highlightedText) {
console.log('原始文本:', JSON.stringify(text))
console.log('高亮后:', highlightedText.replace(/\x1b/g, '\\x1b'))
}
return highlightedText
} catch (error) {
console.error('高亮处理失败:', error)
return text
}
}
// 检测ANSI序列
hasAnsiSequences(text) {
return TerminalHighlighter.ANSI_DETECT.test(text)
}
// 检查是否主要是控制序列
isControlSequenceOnly(text) {
// 移除ANSI序列后,如果剩余内容很少,则认为是控制序列
const cleanText = text.replace(TerminalHighlighter.ANSI_FULL, '').trim()
return cleanText.length < 3 || /^[\s\r\n]*$/.test(cleanText)
}
// 处理包含ANSI序列的文本,保护现有的ANSI序列,只对纯文本部分应用高亮
applyRulesWithAnsi(text) {
// 分离ANSI序列和纯文本
const parts = []
let lastIndex = 0
// 使用全局正则匹配ANSI序列
const matches = text.matchAll(TerminalHighlighter.ANSI_FULL)
for (const match of matches) {
// 添加ANSI序列之前的文本
if (match.index > lastIndex) {
const textPart = text.slice(lastIndex, match.index)
if (textPart) {
parts.push({ type: 'text', content: textPart })
}
}
// 添加ANSI序列
parts.push({ type: 'ansi', content: match[0] })
lastIndex = match.index + match[0].length
}
// 添加最后剩余的文本
if (lastIndex < text.length) {
const textPart = text.slice(lastIndex)
if (textPart) {
parts.push({ type: 'text', content: textPart })
}
}
// 对纯文本部分应用高亮规则
let result = ''
for (const part of parts) {
if (part.type === 'ansi') {
result += part.content
} else {
const highlighted = this.applyRules(part.content)
result += highlighted
}
}
return result
}
// 判断是否跳过处理
shouldSkip(text) {
// 跳过空文本
if (!text.trim()) return true
// 跳过极短文本
if (text.length < 3) return true
// 跳过纯符号文本
if (/^[\W_]+$/.test(text)) return true
return false
}
// 应用所有规则 - 使用分组合并的正则
applyRules(text) {
if (!this.mergedPatterns || this.mergedPatterns.length === 0) {
return text // 没有启用的规则
}
let result = text
// 依次应用每个flags分组的合并正则
for (const group of this.mergedPatterns) {
result = result.replace(group.pattern, (...args) => {
const match = args[0]
// 避免重复高亮已包含ANSI序列的匹配
if (TerminalHighlighter.ANSI_DETECT.test(match)) {
return match
}
// 找到匹配的捕获组
const groups = args.slice(1, -2)
// 找到第一个非undefined的捕获组
for (let i = 0; i < groups.length; i++) {
if (groups[i] !== undefined) {
const rule = group.ruleMap[i + 1]
if (rule) {
return this.applyStyle(match, rule)
}
break
}
}
return match
})
}
return result
}
// 应用样式到匹配文本
applyStyle(text, rule) {
let styleString = ''
// 先添加背景色
if (rule.backgroundColor) {
const bgColor = this.getBackgroundColorCode(rule.backgroundColor)
if (bgColor) {
styleString += bgColor
}
}
// 添加文字样式
if (rule.bold) styleString += TEXT_STYLES.BOLD
if (rule.italic) styleString += TEXT_STYLES.ITALIC
if (rule.underline) styleString += TEXT_STYLES.UNDERLINE
// 添加文本颜色
if (rule.displayColor) {
const textColor = this.getTextColorCode(rule.displayColor)
if (textColor) {
styleString += textColor
}
}
return `${styleString}${text}${TEXT_STYLES.RESET}`
}
// 获取文本颜色 ANSI 代码
getTextColorCode(hexColor) {
if (!hexColor) return ''
// 将十六进制颜色转换为 RGB
const rgb = this.hexToRgb(hexColor)
if (!rgb) return ''
// 使用 RGB 模式 (38;2;r;g;b) 38=前景色(文本), 48=背景色
return `\x1b[38;2;${ rgb.r };${ rgb.g };${ rgb.b }m`
}
// 获取背景色 ANSI 代码
getBackgroundColorCode(hexColor) {
if (!hexColor) return ''
// 将十六进制颜色转换为 RGB
const rgb = this.hexToRgb(hexColor)
if (!rgb) return ''
// 使用 RGB 模式 (48;2;r;g;b) 38=前景色(文本), 48=背景色
return `\x1b[48;2;${ rgb.r };${ rgb.g };${ rgb.b }m`
}
// 十六进制颜色转 RGB
hexToRgb(hex) {
if (!hex) return null
// 移除 # 符号
hex = hex.replace('#', '')
// 简写形式的hex
if (hex.length === 3) {
hex = hex.split('').map(char => char + char).join('')
}
if (hex.length !== 6) return null
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
}
}
// 启用/禁用高亮
setEnabled(enabled) {
this.enabled = enabled
}
// 启用/禁用调试模式
setDebugMode(debugMode) {
this.debugMode = debugMode
if (debugMode) {
console.log('终端高亮调试模式已启用')
} else {
console.log('终端高亮调试模式已禁用')
}
}
// 应用单个规则的HTML预览
applySingleRuleForHtml(text, rule) {
try {
// 使用通用方法获取pattern源码
const patternSource = this.extractPatternSource(rule)
if (!patternSource) {
return '正则表达式格式错误'
}
const flags = rule.flags || 'gi'
const regex = new RegExp(patternSource, flags)
return text.replace(regex, (match) => {
return this.applyHtmlStyle(match, rule)
})
} catch (error) {
return `正则表达式错误: ${ error.message }`
}
}
// 为HTML应用样式
applyHtmlStyle(text, rule) {
let styleProps = ''
// 字体设置
styleProps += 'font-family: monospace !important;'
styleProps += 'font-size: 14px !important;'
// 文字样式
styleProps += 'font-weight: ' + (rule.bold ? 'bold' : 'normal') + ' !important;'
styleProps += 'font-style: ' + (rule.italic ? 'italic' : 'normal') + ' !important;'
// 文本颜色
if (rule.displayColor) {
styleProps += ` color: ${ rule.displayColor } !important;`
}
// 背景色
if (rule.backgroundColor) {
styleProps += ` background-color: ${ rule.backgroundColor } !important;`
}
// 下划线
if (rule.underline) {
styleProps += ' text-decoration: underline !important;'
}
// 基础样式
styleProps += ' padding: 2px 4px !important; border-radius: 3px !important;'
return `<span style="${ styleProps }">${ text }</span>`
}
}
@@ -68,6 +68,39 @@
placeholder="shell script"
/>
</el-form-item>
<el-form-item label="执行模式" prop="useBase64">
<el-radio-group v-model="formData.useBase64">
<el-radio :value="false">
<span>直接执行</span>
<el-tooltip placement="right">
<template #content>
<div style="max-width: 300px;">
适用于单行简单脚本。<br>
脚本内容会直接发送到终端。<br>
注意:多行脚本会逐行自动执行。
</div>
</template>
<el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
<el-radio :value="true">
<span>Base64编码执行</span>
<el-tooltip placement="right">
<template #content>
<div style="max-width: 300px;">
适用于多行复杂脚本。<br>
脚本通过Base64编码后执行,可以避免:<br>
• 特殊字符转义问题<br>
• heredoc标记冲突<br>
• 换行符兼容问题<br>
命令格式: echo '&lt;script&gt;' | base64 -d | bash
</div>
</template>
<el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span>
@@ -81,6 +114,7 @@
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { getCurrentInstance } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
const props = defineProps({
show: {
@@ -122,7 +156,8 @@ const formData = reactive({
name: '',
description: '',
index: nextIndex.value || 0,
command: ''
command: '',
useBase64: false
})
const isModify = computed(() => Boolean(formData.id))
@@ -164,7 +199,8 @@ const handleClose = () => {
name: '',
description: '',
index: nextIndex.value,
command: props.defaultScript || ''
command: props.defaultScript || '',
useBase64: false
})
}
+9
View File
@@ -68,6 +68,7 @@
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="command" label="指令内容" show-overflow-tooltip />
<el-table-column prop="useBase64" label="执行模式" width="140px" :formatter="formatExecutionMode" />
<el-table-column label="操作" fixed="right" width="160px">
<template #default="{ row }">
<template v-if="row.index !== '--'">
@@ -238,6 +239,14 @@ const handleEditSuccess = () => {
currentScript.value = {}
}
// 格式化执行模式显示
const formatExecutionMode = (row, column, cellValue) => {
if (cellValue === true) {
return 'Base64编码执行'
}
return '直接执行'
}
</script>
<style lang="scss" scoped>
@@ -0,0 +1,113 @@
<template>
<el-drawer
v-model="visible"
title="菜单选项"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
:modal="true"
modal-class="menu_options_drawer"
:size="isMobileScreen ? '80%' : '30%'"
>
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="实例" prop="instanceGroup">
<el-radio-group
v-model="hostGroupCascader"
size="small"
text-color="#fff"
fill="#13ce66"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="脚本库" prop="scriptLibrary">
<el-radio-group
v-model="scriptLibraryCascader"
size="small"
text-color="#fff"
:fill="scriptLibrary ? '#13ce66' : '#999'"
:disabled="!scriptLibrary"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
</el-radio-group>
<span class="script_library_switch">
<el-switch
v-model="scriptLibrary"
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999"
active-text="显示"
inactive-text="隐藏"
/>
</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
</span>
</template>
</el-drawer>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import useMobileWidth from '@/composables/useMobileWidth'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
show: {
required: true,
type: Boolean
}
})
const emit = defineEmits(['update:show'])
const visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
const scriptLibrary = computed({
get: () => $store.menuSetting.scriptLibrary,
set: (newVal) => $store.setMenuSetting({ scriptLibrary: newVal })
})
const scriptLibraryCascader = computed({
get: () => $store.menuSetting.scriptLibraryCascader,
set: (newVal) => $store.setMenuSetting({ scriptLibraryCascader: newVal })
})
const hostGroupCascader = computed({
get: () => $store.menuSetting.hostGroupCascader,
set: (newVal) => $store.setMenuSetting({ hostGroupCascader: newVal })
})
</script>
<style lang="scss" scoped>
.dialog_footer {
display: flex;
justify-content: center;
}
.script_library_switch {
margin-left: 20px;
}
</style>
<style lang="scss">
.menu_options_drawer {
.el-drawer__header {
margin-bottom: 0 !important;
}
}
</style>
@@ -0,0 +1,111 @@
<template>
<el-drawer
v-model="visible"
title="其他设置"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
:modal="true"
modal-class="other_setting_drawer"
:size="isMobileScreen ? '80%' : '30%'"
>
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="自动重连" prop="autoReconnect">
<PlusSupportTip>
<span>
<el-switch
v-model="autoReconnect"
class="switch"
inline-prompt
:disabled="!isPlusActive"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
/>
<span class="plus_support_tip_text">(Plus专属功能)</span>
</span>
</PlusSupportTip>
</el-form-item>
<el-form-item label="脚本执行" prop="autoExecuteScript">
<el-tooltip
effect="dark"
content="启用后从脚本库选中脚本后自动执行(回车操作)"
placement="right"
>
<el-switch
v-model="autoExecuteScript"
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="自动"
inactive-text="手动"
/>
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
</span>
</template>
</el-drawer>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import useMobileWidth from '@/composables/useMobileWidth'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
show: {
required: true,
type: Boolean
}
})
const emit = defineEmits(['update:show'])
const visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
const autoReconnect = computed({
get: () => $store.terminalConfig.autoReconnect,
set: (newVal) => $store.setTerminalSetting({ autoReconnect: newVal })
})
const autoExecuteScript = computed({
get: () => $store.terminalConfig.autoExecuteScript,
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
})
const isPlusActive = computed(() => $store.isPlusActive)
</script>
<style lang="scss" scoped>
.dialog_footer {
display: flex;
justify-content: center;
}
</style>
<style lang="scss">
.other_setting_drawer {
.el-drawer__header {
margin-bottom: 0 !important;
}
}
.plus_support_tip_text {
margin-left: 5px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,878 @@
<template>
<el-dialog
v-model="dialogVisible"
title="编辑高亮规则"
width="900px"
:close-on-click-modal="false"
:before-close="handleBeforeClose"
>
<div class="rule-edit">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="规则标题" prop="title">
<el-input
v-model="formData.title"
placeholder="留空则显示默认名称"
maxlength="10"
show-word-limit
clearable
/>
</el-form-item>
<el-form-item label="颜色设置">
<div class="color-row">
<div class="color-item">
<label>文本颜色</label>
<el-color-picker
v-model="formData.displayColor"
size="small"
:predefine="getTextColorPredefines()"
@change="handleColorChange"
/>
</div>
<div class="color-item">
<label>背景颜色</label>
<el-color-picker
v-model="formData.backgroundColor"
size="small"
show-alpha
:predefine="getBackgroundColorPredefines()"
@change="handleBackgroundColorChange"
/>
</div>
</div>
</el-form-item>
<el-form-item label="文字样式">
<el-checkbox-group v-model="textStyles" @change="handleStyleChange">
<el-checkbox value="bold">加粗</el-checkbox>
<el-checkbox value="italic">斜体</el-checkbox>
<el-checkbox value="underline">下划线</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="正则表达式" prop="pattern">
<el-input
v-model="patternSource"
type="textarea"
:rows="3"
placeholder="请输入正则表达式"
@input="updatePattern"
/>
</el-form-item>
<el-form-item label="规则选项">
<el-checkbox v-model="ignoreCase" @change="updatePattern">忽略大小写</el-checkbox>
<el-checkbox v-model="fullLine">高亮整行</el-checkbox>
</el-form-item>
<el-form-item label="测试文本">
<el-input
v-model="testInput"
type="textarea"
:rows="2"
placeholder="输入测试文本"
/>
</el-form-item>
<el-form-item label="匹配结果">
<div class="test-result">
<div v-if="testMatches.length" class="matches">
<el-tag
v-for="(match, index) in testMatches"
:key="index"
size="small"
type="success"
class="match-tag"
>
{{ match }}
</el-tag>
</div>
<el-text v-else size="small" type="info">
无匹配结果
</el-text>
</div>
</el-form-item>
<el-form-item label="预览效果">
<div class="preview-result" v-html="previewHtml" />
</el-form-item>
<el-form-item label="常用模板">
<div class="templates">
<el-button
v-for="template in getTemplates(ruleName)"
:key="template.name"
size="small"
@click="applyTemplate(template)"
>
{{ template.name }}
</el-button>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch, getCurrentInstance, nextTick } from 'vue'
import { HIGHLIGHT_RULES, TerminalHighlighter } from '@/utils/highlighter'
const { proxy: { $message, $messageBox } } = getCurrentInstance()
// 检查颜色是否已被使用
const isColorUsed = (color, rules, excludeKey = null) => {
const usedColors = Object.entries(rules)
.filter(([key,]) => key !== excludeKey)
.map(([, rule,]) => rule.displayColor.toLowerCase())
return usedColors.includes(color.toLowerCase())
}
const props = defineProps({
show: {
type: Boolean,
default: false
},
ruleName: {
type: String,
default: ''
},
ruleData: {
type: Object,
default: () => null
},
allRules: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:show', 'save',])
const dialogVisible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const formRef = ref(null)
const patternSource = ref('')
const ignoreCase = ref(true)
const fullLine = ref(false)
const testInput = ref('')
const textStyles = ref([])
// 创建一个用于预览的highlighter实例
const previewHighlighter = new TerminalHighlighter(null, {
enabled: true,
debugMode: false
})
// 原始数据,用于检测变化
const originalData = ref({})
const formData = ref({
title: '',
pattern: null,
flags: 'gi',
fullLine: false,
displayColor: '',
backgroundColor: null,
bold: false,
italic: false,
underline: false,
enabled: true
})
const formRules = {
title: [
{ max: 10, message: '标题长度最多 10 个字符', trigger: 'blur' },
],
pattern: [
{
required: true,
validator: (_rule, _value, callback) => {
if (!patternSource.value || patternSource.value.trim() === '') {
callback(new Error('请输入正则表达式'))
} else {
try {
new RegExp(patternSource.value, patternFlags.value)
callback()
} catch (error) {
callback(new Error('正则表达式格式错误'))
}
}
},
trigger: 'blur'
},
]
}
// 计算属性
const patternFlags = computed(() => ignoreCase.value ? 'gi' : 'g')
const testMatches = computed(() => {
if (!patternSource.value || !testInput.value) return []
try {
const regex = new RegExp(patternSource.value, patternFlags.value)
const matches = testInput.value.match(regex) || []
return [...new Set(matches),] // 去重
} catch (error) {
return []
}
})
const previewHtml = computed(() => {
if (!patternSource.value || !testInput.value) {
return testInput.value || '请输入测试文本'
}
try {
// 构造当前编辑的规则对象
const currentRule = {
pattern: new RegExp(patternSource.value, patternFlags.value),
flags: patternFlags.value,
fullLine: fullLine.value,
displayColor: formData.value.displayColor,
backgroundColor: formData.value.backgroundColor,
bold: formData.value.bold,
italic: formData.value.italic,
underline: formData.value.underline,
enabled: true
}
// 使用highlighter的HTML预览方法
return previewHighlighter.applySingleRuleForHtml(testInput.value, currentRule)
} catch (error) {
return `正则表达式错误: ${ error.message }`
}
})
// 获取文本颜色预定义值
const getTextColorPredefines = () => {
return [
// 默认规则的文本颜色
'#ff4d4f', // rule1 - 错误红色
'#fadb14', // rule2 - 警告黄色
'#52c41a', // rule3 - 成功绿色
'#13c2c2', // rule4 - 信息青色
'#eb2f96', // rule5 - 网络洋红
'#1890ff', // rule6 - 链接蓝色
'#ffffff', // rule7 - 日期时间白色
'#8b5cf6', // rule8 - 单位数据紫色
// 基础色
'#000000', // 黑色
'#808080', // 灰色
'#c0c0c0', // 浅灰色
'#404040', // 深灰色
// 其他颜色
'#ef4444', '#f97316', '#f59e0b', '#eab308',
'#84cc16', '#22c55e', '#10b981', '#14b8a6',
'#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1',
'#a855f7', '#d946ef', '#ec4899', '#f43f5e',
]
}
// 获取背景颜色预定义值
const getBackgroundColorPredefines = () => {
return [
// 基础色
'transparent',
'#000000', // 黑色
'#ffffff', // 白色
'#404040', // 深灰色
'#808080', // 中灰色
'#c0c0c0', // 浅灰色
// 深色背景
'#1f2937', // 深蓝灰
'#374151', // 灰蓝色
'#4b5563', // 石板灰
'#6b7280', // 钢灰色
'#fa541c', // 橙色
'#dc2626', // 深红色
'#b91c1c', // 暗红色
'#7c3aed', // 深紫色
// 中等饱和度
'#ef4444', // 红色
'#f97316', // 橙色
'#eab308', // 黄色
'#22c55e', // 绿色
'#06b6d4', // 青色
'#3b82f6', // 蓝色
'#8b5cf6', // 紫色
'#ec4899', // 粉色
// 其他颜色
'#1e40af', '#7c2d12', '#166534', '#0f766e',
'#0c4a6e', '#581c87', '#9d174d', '#be123c',
]
}
// 颜色变化处理
const handleColorChange = (newColor) => {
if (!newColor) return
// 检查颜色是否重复
if (isColorUsed(newColor, props.allRules, props.ruleName)) {
$message.warning('该颜色已被其他规则使用,请选择其他颜色')
// 阻止更新,强制恢复原颜色
const originalColor = props.ruleData?.displayColor || HIGHLIGHT_RULES[props.ruleName].displayColor
// 使用nextTick确保在下一个事件循环中恢复颜色
nextTick(() => {
formData.value.displayColor = originalColor
})
return
}
formData.value.displayColor = newColor
}
// 背景颜色变化处理
const handleBackgroundColorChange = (newColor) => {
formData.value.backgroundColor = newColor
}
// 文字样式变化处理
const handleStyleChange = (styles) => {
formData.value.bold = styles.includes('bold')
formData.value.italic = styles.includes('italic')
formData.value.underline = styles.includes('underline')
}
// 获取模板列表
const getTemplates = (ruleName) => {
// 从默认规则中提取模板
const defaultRule = HIGHLIGHT_RULES[ruleName]
const templates = [
// 第一个模板:真实的默认规则
{
name: '默认规则(完整)',
pattern: defaultRule.pattern.source,
flags: defaultRule.flags.split('')
},
]
// 根据不同颜色添加特定的常用模板
const additionalTemplates = getAdditionalTemplates(ruleName)
templates.push(...additionalTemplates)
return templates
}
// 获取各颜色的额外模板
const getAdditionalTemplates = (ruleName) => {
const templateMap = {
rule1: [
{
name: '基础错误关键词',
pattern: '\\b(error|err|failed?|failure|fatal|critical)\\b',
flags: ['g', 'i',]
},
{
name: '系统崩溃异常',
pattern: '\\b(crash(ed)?|exception|panic|abort(ed)?|kill(ed)?|terminate(d)?|dead|died)\\b',
flags: ['g', 'i',]
},
{
name: '拒绝访问类',
pattern: '\\b(denied|refused|forbidden|unauthorized|blocked|locked|invalid)\\b',
flags: ['g', 'i',]
},
{
name: '连接超时',
pattern: '\\b(timeout|disconnect(ed)?|unreachable|unavailable|missing|not found)\\b',
flags: ['g', 'i',]
},
{
name: '安全威胁',
pattern: '\\b(virus|breach|hack(ed)?|attack|exploit|vulnerability|malicious|infected|compromised|hijacked|suspicious|illegal)\\b',
flags: ['g', 'i',]
},
],
rule2: [
{
name: '基础警告关键词',
pattern: '\\b(warn(ing)?|deprecated|caution)\\b',
flags: ['g', 'i',]
},
{
name: '重试延迟状态',
pattern: '\\b(retry|retrying|retried|delay(ed)?|slow|slower|pending|waiting)\\b',
flags: ['g', 'i',]
},
{
name: '临时状态',
pattern: '\\b(temporary|temp|experimental|beta|alpha|preview|unstable)\\b',
flags: ['g', 'i',]
},
{
name: '安全风险',
pattern: '\\b(insecure|vulnerable|risky|outdated|obsolete)\\b',
flags: ['g', 'i',]
},
{
name: '系统维护',
pattern: '\\b(maintenance|migration|backup|fallback|suspend(ed)?|pause(d)?)\\b',
flags: ['g', 'i',]
},
],
rule3: [
{
name: '基础成功关键词',
pattern: '\\b(success(ful)?|successfully|complete(d)?|completed|ok(ay)?|done)\\b',
flags: ['g', 'i',]
},
{
name: '运行启动状态',
pattern: '\\b(running|active|online|ready|started|start|begin|launch(ed)?)\\b',
flags: ['g', 'i',]
},
{
name: '连接可用状态',
pattern: '\\b(connect(ed)?|available|enabled|online|accessible)\\b',
flags: ['g', 'i',]
},
{
name: '验证通过状态',
pattern: '\\b(valid|verified|confirmed|approved|passed|accepted|authenticated|authorized)\\b',
flags: ['g', 'i',]
},
{
name: '安装部署状态',
pattern: '\\b(installed|deployed|built|compiled|loaded|mounted|updated|upgraded)\\b',
flags: ['g', 'i',]
},
],
rule4: [
{
name: '基础信息关键词',
pattern: '\\b(info|information|notice|message|log)\\b',
flags: ['g', 'i',]
},
{
name: '调试日志级别',
pattern: '\\b(debug|trace|verbose|log|report)\\b',
flags: ['g', 'i',]
},
{
name: '处理连接状态',
pattern: '\\b(processing|loading|connecting|checking|monitoring)\\b',
flags: ['g', 'i',]
},
{
name: '配置初始化',
pattern: '\\b(config|configuration|setting|setup|initializing|preparing)\\b',
flags: ['g', 'i',]
},
{
name: '构建编译状态',
pattern: '\\b(building|compiling|parsing|analyzing|scanning)\\b',
flags: ['g', 'i',]
},
],
rule5: [
{
name: 'IPv4地址',
pattern: '\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b',
flags: ['g',]
},
{
name: 'IP:端口组合',
pattern: '\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{1,5}\\b',
flags: ['g',]
},
{
name: 'IPv6完整格式',
pattern: '\\b([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\b',
flags: ['g',]
},
{
name: 'IPv6简化格式',
pattern: '\\b([0-9a-fA-F]{1,4}:)*::([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{0,4}\\b',
flags: ['g',]
},
{
name: 'MAC地址',
pattern: '\\b([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\\b|\\b([0-9a-fA-F]{2}-){5}[0-9a-fA-F]{2}\\b',
flags: ['g',]
},
{
name: '内网IPv4地址',
pattern: '\\b(10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3})\\b',
flags: ['g',]
},
],
rule6: [
{
name: 'HTTP/HTTPS链接',
pattern: 'https?:\\/\\/[^\\s]+',
flags: ['g', 'i',]
},
{
name: 'FTP链接',
pattern: 'ftps?:\\/\\/[^\\s]+',
flags: ['g', 'i',]
},
{
name: '邮箱地址',
pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}',
flags: ['g', 'i',]
},
{
name: '文件路径(Unix)',
pattern: '\\/[\\w\\-._\\/]*[\\w\\-._]',
flags: ['g',]
},
{
name: '文件路径(Windows)',
pattern: '[A-Z]:\\\\[\\w\\-._\\\\\\s]*[\\w\\-._]',
flags: ['g', 'i',]
},
{
name: 'www域名',
pattern: 'www\\.[^\\s]+\\.[a-z]{2,}[^\\s]*',
flags: ['g', 'i',]
},
],
rule7: [
{
name: 'ISO 8601格式',
pattern: '\\b\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?[Zz]?\\b',
flags: ['g',]
},
{
name: '标准日期格式',
pattern: '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}\\b|\\b\\d{1,2}[-/]\\d{1,2}[-/]\\d{2,4}\\b',
flags: ['g',]
},
{
name: '时间格式',
pattern: '\\b\\d{1,2}:\\d{1,2}(:\\d{1,2})?(\\.\\d{1,6})?\\b',
flags: ['g',]
},
{
name: '12小时制时间',
pattern: '\\b\\d{1,2}:\\d{1,2}(:\\d{1,2})?(\\.\\d{1,6})?\\s?[AaPp][Mm]\\b',
flags: ['g',]
},
{
name: '日志时间戳',
pattern: '\\[\\d{4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?\\]',
flags: ['g',]
},
{
name: 'Unix时间戳',
pattern: '\\b\\d{10,13}\\b',
flags: ['g',]
},
],
rule8: [
{
name: '存储单位',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:TiB|GiB|MiB|KiB|TB|GB|MB|KB|B)\\b',
flags: ['g', 'i',]
},
{
name: '网络速度',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:Tbps|Gbps|Mbps|Kbps|bps)\\b',
flags: ['g', 'i',]
},
{
name: '时间单位',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:ns|μs|ms|min|hrs?)\\b',
flags: ['g', 'i',]
},
{
name: '温度单位',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:°C|°F|K)\\b',
flags: ['g', 'i',]
},
{
name: '频率单位',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:Hz|KHz|MHz|GHz|THz)\\b',
flags: ['g', 'i',]
},
{
name: '百分比',
pattern: '\\b(?:\\d+(?:\\.\\d+)?|100(?:\\.0+)?)\\s*%',
flags: ['g',]
},
{
name: '性能指标',
pattern: '\\b\\d+(?:\\.\\d+)?\\s*(?:ops[/]s|req[/]s|qps|tps|rps|iops|IOPS|pps|PPS)\\b',
flags: ['g', 'i',]
},
{
name: '完整时间描述',
pattern: '\\b\\d+(?:\\.\\d+)?\\s+(?:milliseconds?|seconds?|minutes?|hours?|days?|weeks?|months?|years?)\\b',
flags: ['g', 'i',]
},
]
}
return templateMap[ruleName] || []
}
// 方法
const updatePattern = () => {
try {
formData.value.pattern = new RegExp(patternSource.value, patternFlags.value)
} catch (error) {
console.error('正则表达式无效:', error)
}
}
const applyTemplate = (template) => {
patternSource.value = template.pattern
ignoreCase.value = template.flags.includes('i')
updatePattern()
}
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
updatePattern()
if (!formData.value.pattern) {
throw new Error('正则表达式无效')
}
// 最终检查颜色是否重复
if (isColorUsed(formData.value.displayColor, props.allRules, props.ruleName)) {
$message.error('该颜色已被其他规则使用,无法保存')
return
}
emit('save', props.ruleName, {
title: formData.value.title,
pattern: formData.value.pattern,
flags: patternFlags.value,
fullLine: fullLine.value,
displayColor: formData.value.displayColor,
backgroundColor: formData.value.backgroundColor,
bold: formData.value.bold,
italic: formData.value.italic,
underline: formData.value.underline,
enabled: formData.value.enabled
})
} catch (error) {
console.error('保存失败:', error)
}
}
// 检测数据是否发生变化
const hasDataChanged = () => {
if (!originalData.value || !Object.keys(originalData.value).length) return false
const current = {
title: formData.value.title,
displayColor: formData.value.displayColor,
backgroundColor: formData.value.backgroundColor,
bold: formData.value.bold,
italic: formData.value.italic,
underline: formData.value.underline,
enabled: formData.value.enabled,
patternSource: patternSource.value,
ignoreCase: ignoreCase.value,
fullLine: fullLine.value
}
return JSON.stringify(current) !== JSON.stringify(originalData.value)
}
// 关闭前确认
const handleBeforeClose = async (done) => {
if (hasDataChanged()) {
try {
await $messageBox.confirm(
'您有未保存的修改,确定要关闭吗?',
'确认关闭',
{
type: 'warning',
confirmButtonText: '确定关闭',
cancelButtonText: '继续编辑'
}
)
done()
} catch {
// 用户取消,不关闭
}
} else {
done()
}
}
const handleCancel = async () => {
await handleBeforeClose(() => {
dialogVisible.value = false
})
}
// 监听
watch(() => props.show, (show) => {
if (show && props.ruleData) {
// 获取默认规则作为基础
const defaultRule = HIGHLIGHT_RULES[props.ruleName]
// 使用 ?? 空值合并运算符,只有 null/undefined 时才使用默认值
formData.value = {
title: props.ruleData.title ?? defaultRule.title,
displayColor: props.ruleData.displayColor ?? defaultRule.displayColor,
backgroundColor: props.ruleData.backgroundColor ?? defaultRule.backgroundColor,
bold: props.ruleData.bold ?? defaultRule.bold,
italic: props.ruleData.italic ?? defaultRule.italic,
underline: props.ruleData.underline ?? defaultRule.underline,
enabled: props.ruleData.enabled !== false
}
// 标准RegExp对象处理
const patternData = props.ruleData.pattern ?? defaultRule.pattern
if (patternData && patternData.source) {
patternSource.value = patternData.source
ignoreCase.value = (props.ruleData.flags ?? defaultRule.flags ?? 'gi').includes('i')
} else {
patternSource.value = ''
ignoreCase.value = true
console.warn('规则数据中的pattern格式不正确:', props.ruleData.pattern)
}
// 初始化 fullLine 状态
fullLine.value = props.ruleData.fullLine ?? defaultRule.fullLine
// 初始化样式复选框状态
const styles = []
if (formData.value.bold) styles.push('bold')
if (formData.value.italic) styles.push('italic')
if (formData.value.underline) styles.push('underline')
textStyles.value = styles
// 保存原始数据用于变化检测
originalData.value = {
title: formData.value.title,
displayColor: formData.value.displayColor,
backgroundColor: formData.value.backgroundColor,
bold: formData.value.bold,
italic: formData.value.italic,
underline: formData.value.underline,
enabled: formData.value.enabled,
patternSource: patternSource.value,
ignoreCase: ignoreCase.value,
fullLine: fullLine.value
}
// 默认的规则测试文本
if (!testInput.value) {
testInput.value = `🔴 ERRORS: fatal error! system crashed, expires certificate, virus attack, unauthorized blocked
🟡 WARNINGS: deprecated warning! experimental feature, vulnerable system, maintenance mode
🟢 SUCCESS: login successful established! task completed, system healthy, verified passed
🔵 INFO: loading configuration, debugging trace, sleeping zombie processes, stopped
🌐 NETWORK: 192.168.1.100:8080, 2001:db8::1, aa:bb:cc:dd:ee:ff
🔗 URLS: https://api.example.com/v1, mailto:admin@domain.com, /usr/local/bin
📅 DATETIME: 2024-01-15T10:30:45Z, 3:45 PM, 01/15/2024
💾 UNITS: CPU 3.2GHz 68°C, RAM 16GB 85%, Network 1Gbps, Process 2 hours 30 minutes`
}
}
})
</script>
<style lang="scss" scoped>
.rule-edit {
.rule-options {
display: flex;
gap: 30px;
align-items: center;
.option-item {
display: flex;
align-items: center;
gap: 12px;
label {
font-size: 14px;
color: var(--el-text-color-regular);
min-width: 80px;
white-space: nowrap;
}
}
}
.color-row {
display: flex;
gap: 20px;
align-items: center;
.color-item {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: var(--el-text-color-regular);
min-width: 60px;
white-space: nowrap;
}
}
}
.color-info {
display: flex;
align-items: center;
.color-selector {
margin-left: auto;
}
}
.test-result {
.matches {
.match-tag {
margin-right: 8px;
margin-bottom: 4px;
}
}
}
.preview-result {
padding: 12px;
background-color: #1e1e1e;
color: #ffffff;
border-radius: 4px;
font-family: Cascadia Code, Menlo, monospace;
font-weight: normal;
font-style: normal;
min-height: 60px;
line-height: 1.5;
}
.templates {
.el-button {
margin-right: 8px;
margin-bottom: 8px;
}
}
}
.dialog-footer {
text-align: right;
}
</style>
+101 -4
View File
@@ -134,6 +134,14 @@
<View v-if="showHidden" />
<Hide v-else />
</el-icon>
<el-icon
ref="columnsConfigPopoverRef"
class="action_icon"
title="列配置"
@click="showColumnsConfig = !showColumnsConfig"
>
<Setting />
</el-icon>
<el-icon
v-if="hasDownloadTasks"
class="action_icon download_icon"
@@ -170,11 +178,27 @@
<el-table-column
label="名称"
width="auto"
max-width="200"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<div class="file_name_cell">
<el-tooltip
placement="right"
effect="dark"
:show-after="500"
>
<template #content>
<div style="font-size: 12px; line-height: 1.6;">
<div><strong>名称:</strong> {{ row.name }}</div>
<div><strong>类型:</strong> {{ row.type === 'd' ? '文件夹' : row.type === 'l' ? '链接' : '文件' }}</div>
<div v-if="row.size"><strong>大小:</strong> {{ sizeFormatter(row, null, row.size) }}</div>
<div v-if="row.modifyTime"><strong>修改时间:</strong> {{ timeFormatter(row, null, row.modifyTime) }}</div>
<div v-if="row.permissions"><strong>权限:</strong> {{ row.permissions }}</div>
<div v-if="row.ownerName"><strong>所有者:</strong> {{ row.ownerName }}</div>
<div v-if="row.groupName"><strong>组:</strong> {{ row.groupName }}</div>
</div>
</template>
<div class="file_name_cell">
<img :src="getIcon(row.type)" class="file_icon">
<template v-if="isEditing(row)">
<el-input
@@ -201,20 +225,35 @@
</el-icon>
</template>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
v-if="columnsConfig.size"
prop="size"
label="大小"
:formatter="sizeFormatter"
width="70"
width="55"
/>
<el-table-column
v-if="columnsConfig.modifyTime"
prop="modifyTime"
label="修改时间"
width="80"
:formatter="timeFormatter"
/>
<el-table-column
v-if="columnsConfig.permissions"
prop="permissions"
label="权限"
width="80"
/>
<el-table-column
v-if="columnsConfig.owner"
prop="ownerName"
label="所有者"
width="70"
/>
<!-- 权限列已隐藏,根据需求可再启用 -->
</el-table>
@@ -286,6 +325,29 @@
</template>
</el-popover>
<!-- 列配置 Popover -->
<el-popover
v-model:visible="showColumnsConfig"
:virtual-ref="columnsConfigPopoverRef"
width="200"
trigger="manual"
placement="bottom-end"
>
<template #default>
<div style="padding: 8px 0;">
<div style="font-weight: 500; margin-bottom: 8px; padding: 0 12px;">显示列</div>
<el-checkbox-group v-model="selectedColumns" style="display: flex; flex-direction: column; gap: 8px; padding: 0 12px;">
<el-checkbox
v-for="col in availableColumns"
:key="col.key"
:value="col.key"
:label="col.label"
/>
</el-checkbox-group>
</div>
</template>
</el-popover>
<!-- 隐藏的文件选择器 -->
<input
ref="uploadInputRef"
@@ -589,7 +651,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, getCurrentInstance, nextTick } from 'vue'
import { ArrowDown, ArrowLeft, Refresh, View, Hide, Edit, Search, ArrowRight, HomeFilled, Check, Close as CloseIcon, Download, Upload, DocumentCopy, Loading, WarningFilled, Star, StarFilled, Delete, SwitchButton } from '@element-plus/icons-vue'
import { ArrowDown, ArrowLeft, Refresh, View, Hide, Edit, Search, ArrowRight, HomeFilled, Check, Close as CloseIcon, Download, Upload, DocumentCopy, Loading, WarningFilled, Star, StarFilled, Delete, SwitchButton, Setting } from '@element-plus/icons-vue'
import { generateSocketInstance } from '@/utils'
import dirIcon from '@/assets/image/system/dir.png'
import linkIcon from '@/assets/image/system/link.png'
@@ -619,6 +681,30 @@ const props = defineProps({
// 组件实例上下文
const { proxy: { $message, $messageBox } } = getCurrentInstance()
// 列配置
const COLUMNS_CONFIG_KEY = 'easynode_sftp_columns_config'
const availableColumns = [
{ key: 'size', label: '大小', width: 70, defaultShow: true },
{ key: 'modifyTime', label: '修改时间', width: 80, defaultShow: true },
{ key: 'permissions', label: '权限', width: 80, defaultShow: false },
{ key: 'owner', label: '所有者', width: 70, defaultShow: false },
]
const defaultColumnsConfig = availableColumns.reduce((acc, col) => {
acc[col.key] = col.defaultShow
return acc
}, {})
const columnsConfig = ref(
JSON.parse(localStorage.getItem(COLUMNS_CONFIG_KEY) || JSON.stringify(defaultColumnsConfig))
)
watch(columnsConfig, (val) => {
localStorage.setItem(COLUMNS_CONFIG_KEY, JSON.stringify(val))
}, { deep: true })
const showColumnsConfig = ref(false)
// 路径 & 隐藏文件显示
const currentPath = ref('/')
const SHOW_HIDDEN_KEY = 'easynode_show_hidden_files'
@@ -1474,6 +1560,17 @@ const search = () => {
}, 100)
}
// 列配置相关
const columnsConfigPopoverRef = ref(null)
const selectedColumns = computed({
get: () => Object.keys(columnsConfig.value).filter(key => columnsConfig.value[key]),
set: (newValue) => {
availableColumns.forEach(col => {
columnsConfig.value[col.key] = newValue.includes(col.key)
})
}
})
const confirmPathInput = () => {
if (!pathInput.value) return
isEditingPath.value = false
@@ -0,0 +1,757 @@
<template>
<el-drawer
v-model="dialogVisible"
title="高亮设置"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
:modal="true"
modal-class="local_setting_drawer"
:size="isMobileScreen ? '80%' : '35%'"
>
<el-form
ref="formRef"
label-suffix=":"
label-width="130px"
:show-message="false"
>
<el-form-item label="关键词高亮">
<el-switch
v-model="highlightConfig.enabled"
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
@change="handleConfigChange"
/>
</el-form-item>
<el-form-item v-if="highlightConfig.enabled" label="调试模式">
<el-switch
v-model="highlightConfig.debugMode"
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
@change="handleConfigChange"
/>
<el-text class="debug-tip" size="small" type="info">
开启后会在控制台显示详细的高亮处理信息
</el-text>
</el-form-item>
<!-- 规则管理按钮 -->
<el-form-item v-if="highlightConfig.enabled" label="规则管理">
<div class="rule-actions">
<el-button type="warning" size="small" @click="handleRestoreDefaults">
恢复默认
</el-button>
<el-button type="primary" size="small" @click="handleExportRules">
导出规则
</el-button>
<el-button type="success" size="small" @click="handleImportRules">
导入规则
</el-button>
</div>
</el-form-item>
<!-- 高亮规则列表 -->
<div v-if="highlightConfig.enabled" class="rules-section">
<div
v-for="(rule, ruleName) in highlightRules"
:key="ruleName"
class="rule-item"
>
<el-form-item :label="getRuleTitle(ruleName)">
<div class="rule-content">
<div class="rule-header">
<!-- 启用切换开关 -->
<div class="rule-enable">
<el-switch
v-model="rule.enabled"
size="small"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="启用"
inactive-text="禁用"
@change="handleRuleEnabledChange(ruleName, rule.enabled)"
/>
</div>
<div class="color-info">
<!-- 文本颜色预览 -->
<div class="color-display">
<span class="color-label">文本</span>
<span
class="color-preview"
:style="{ backgroundColor: getColorDisplay(rule) }"
:title="`文本颜色: ${getColorDisplay(rule)}`"
/>
</div>
<!-- 背景色预览 -->
<div v-if="rule.backgroundColor" class="color-display">
<span class="color-label">背景</span>
<span
class="background-preview"
:style="{ backgroundColor: rule.backgroundColor }"
:title="`背景色: ${rule.backgroundColor}`"
/>
</div>
<!-- 样式标签 -->
<div class="style-tags">
<el-tag v-if="rule.bold" size="small" type="danger" effect="dark">粗体</el-tag>
<el-tag v-if="rule.italic" size="small" type="warning" effect="dark">斜体</el-tag>
<el-tag v-if="rule.underline" size="small" type="info" effect="dark">下划线</el-tag>
</div>
</div>
<div class="rule-buttons">
<el-button
type="primary"
size="small"
link
:disabled="!rule.enabled"
@click="editRule(ruleName)"
>
编辑
</el-button>
</div>
</div>
</div>
</el-form-item>
</div>
</div>
<!-- 预览效果 -->
<el-form-item v-if="highlightConfig.enabled" label="预览效果">
<div class="test-panel">
<el-input
v-model="testText"
type="textarea"
:rows="2"
placeholder="输入测试文本,查看高亮效果"
class="test-input"
/>
</div>
</el-form-item>
<!-- 预览输出 -->
<div
v-if="highlightConfig.enabled"
class="test-output"
:style="testOutputStyle"
v-html="highlightedTestText"
/>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="handleCancel">关闭</el-button>
</span>
</template>
<!-- 规则编辑对话框 -->
<RuleEditDialog
v-model:show="showRuleEdit"
:rule-name="editingRuleName"
:rule-data="editingRule"
:all-rules="highlightRules"
@save="handleRuleSave"
/>
<!-- 导入对话框 -->
<el-dialog
v-model="showImportDialog"
title="导入高亮规则"
width="600px"
>
<el-input
v-model="importRulesText"
type="textarea"
:rows="10"
placeholder="请粘贴规则JSON内容"
/>
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="confirmImportRules">确认导入</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue'
import { HIGHLIGHT_RULES } from '@/utils/highlighter'
import useMobileWidth from '@/composables/useMobileWidth'
import RuleEditDialog from './rule-edit-dialog.vue'
import themeList from 'xterm-theme'
const { proxy: { $store, $message, $messageBox } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:show',])
const dialogVisible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
// 高亮配置
const highlightConfig = ref({
enabled: true,
debugMode: false
})
// 高亮规则
const highlightRules = ref({ ...HIGHLIGHT_RULES })
// 预览测试
const testText = ref('ERRORS: fatal error! system crashed!\nWARNINGS: deprecated warning! Retrying!\nSUCCESS: login successful established\nINFO: loading configuration, sleeping processes\nNETWORK: 192.168.1.100:8080,2001:db8::1\nURLS: https://api.example.com/v1, git@github.com\nDATETIME: 2024-01-15T10:30:45Z, 3:45 PM\nUNITS: CPU 3.2GHz, RAM 16GB, Network 1Gbps')
const showRuleEdit = ref(false)
const editingRuleName = ref('')
const editingRule = ref(null)
const showImportDialog = ref(false)
const importRulesText = ref('')
// 将RegExp转换为可JSON化的对象
const serializeRules = (rules) => {
const serialized = {}
for (const [key, rule] of Object.entries(rules)) {
serialized[key] = {
...rule,
pattern: rule.pattern instanceof RegExp ? {
source: rule.pattern.source,
flags: rule.flags
} : rule.pattern
}
}
return serialized
}
// 创建默认规则
const createDefaultRules = () => {
const defaults = {}
for (const [key, rule] of Object.entries(HIGHLIGHT_RULES)) {
defaults[key] = {
title: rule.title,
pattern: rule.pattern,
flags: rule.flags,
displayColor: rule.displayColor,
backgroundColor: rule.backgroundColor,
bold: rule.bold,
italic: rule.italic,
underline: rule.underline,
fullLine: rule.fullLine,
enabled: rule.enabled !== false
}
}
return defaults
}
const getRuleTitle = (ruleName) => {
const rule = highlightRules.value[ruleName]
return rule.title || ruleName
}
const getColorDisplay = (rule) => {
return rule.displayColor
}
// 获取用户终端配置
const userFontFamily = computed(() => {
return $store.terminalConfig?.fontFamily || 'monospace'
})
const userBackground = computed(() => {
return $store.terminalConfig?.background || ''
})
const userTheme = computed(() => {
const themeName = $store.terminalConfig?.themeName || 'Afterglow'
return themeList[themeName] || themeList['Afterglow']
})
const userFontColor = computed(() => {
return $store.terminalConfig?.fontColor || ''
})
// 预览输出样式
const testOutputStyle = computed(() => {
const style = {
fontFamily: userFontFamily.value,
fontSize: '14px'
}
// 如果有自定义背景
if (userBackground.value) {
// 判断背景类型
if (userBackground.value.startsWith('http')) {
// 图片URL:透明背景 + 背景图
style.backgroundColor = 'transparent'
style.backgroundImage = `url(${userBackground.value})`
style.backgroundSize = 'cover'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
} else {
// 渐变色或纯色:直接使用该值作为背景
style.backgroundColor = 'transparent'
style.backgroundImage = userBackground.value
style.backgroundSize = '100% 100%'
style.backgroundRepeat = 'no-repeat'
}
} else {
// 使用主题背景色
style.backgroundColor = userTheme.value.background || '#1e1e1e'
}
// 如果有自定义字体颜色
if (userFontColor.value) {
style.color = userFontColor.value
} else {
// 使用主题字体颜色
style.color = userTheme.value.foreground || '#ffffff'
}
return style
})
// 高亮预览效果中的测试文本,专门兼容html的标签问题
const highlightedTestText = computed(() => {
if (!testText.value || !highlightConfig.value.enabled) {
return testText.value
}
try {
const source = testText.value + '' // 强制创建新字符串
let output = source
// 处理所有规则
const activeRules = Object.entries(highlightRules.value)
.filter(([, rule,]) => rule.enabled && rule.pattern)
for (const [ruleName, rule,] of activeRules) {
// 创建新的正则表达式实例,避免全局状态问题
const regex = new RegExp(rule.pattern.source, rule.flags)
// 先找到所有匹配位置,检查是否在HTML标签内
const matches = []
let match
regex.lastIndex = 0
while ((match = regex.exec(output)) !== null) {
matches.push({
index: match.index,
text: match[0]
})
}
// 从后往前替换,避免索引偏移问题
for (let i = matches.length - 1; i >= 0; i--) {
const { index, text } = matches[i]
// 检查匹配位置是否在HTML标签内部
const before = output.substring(0, index)
const lastOpenTag = before.lastIndexOf('<')
const lastCloseTag = before.lastIndexOf('>')
// 如果最近的 < 比 > 更近,说明在标签内部,跳过
if (lastOpenTag > lastCloseTag) {
continue
}
// 构建样式字符串
let styles = ''
// 字体
styles += 'font-family:' + userFontFamily.value + ' !important;'
styles += 'font-size:14px !important;'
styles += 'font-weight:' + (rule.bold ? 'bold' : 'normal') + ' !important;'
styles += 'font-style:' + (rule.italic ? 'italic' : 'normal') + ' !important;'
// 颜色
styles += 'color:' + rule.displayColor + ' !important;'
// 添加背景色
if (rule.backgroundColor) {
styles += 'background-color:' + rule.backgroundColor + ' !important;'
}
if (rule.underline) {
styles += 'text-decoration:underline !important;'
}
// 添加padding,效果更明显
if (rule.backgroundColor) {
styles += 'padding:2px 4px !important;border-radius:3px !important;'
}
const replacement = '<span style="' + styles + '">' + text + '</span>'
// 替换
output = output.substring(0, index) + replacement + output.substring(index + text.length)
}
}
const finalOutput = output.replace(/\n/g, '<br>')
return finalOutput
} catch (error) {
console.error('预览错误:', error)
return testText.value.replace(/\n/g, '<br>')
}
})
// 加载当前配置
const loadConfig = () => {
const terminalConfig = $store.terminalConfig
highlightConfig.value = {
enabled: terminalConfig.keywordHighlight !== false,
debugMode: terminalConfig.highlightDebugMode || false
}
// 如果有自定义规则,尝试使用自定义规则;如果无有效规则则使用默认规则
let useDefaultRules = true
if (terminalConfig.customHighlightRules) {
// 标准化自定义规则,确保pattern是RegExp对象
const customRules = {}
for (const [key, rule,] of Object.entries(terminalConfig.customHighlightRules)) {
// 跳过无效的规则
if (!rule.pattern || (typeof rule.pattern === 'object' && !rule.pattern.source)) {
console.warn(`跳过无效的自定义规则 ${ key }:`, rule.pattern)
continue
}
let validPattern
if (rule.pattern instanceof RegExp) {
validPattern = rule.pattern
} else if (rule.pattern.source) {
validPattern = new RegExp(rule.pattern.source, rule.flags || 'gi')
} else {
console.warn(`跳过无法解析的规则 ${ key }:`, rule.pattern)
continue
}
customRules[key] = {
...rule,
title: rule.title || '',
pattern: validPattern,
flags: rule.flags || 'gi',
backgroundColor: rule.backgroundColor || null,
bold: rule.bold || false,
italic: rule.italic || false,
underline: rule.underline || false
}
}
// 如果有有效的自定义规则,使用它们,否则使用默认规则
if (Object.keys(customRules).length > 0) {
highlightRules.value = customRules
useDefaultRules = false
}
}
if (useDefaultRules) {
highlightRules.value = createDefaultRules()
}
}
// 编辑规则
const editRule = (ruleName) => {
editingRuleName.value = ruleName
editingRule.value = { ...highlightRules.value[ruleName] }
showRuleEdit.value = true
}
// 处理规则启用状态变化
const handleRuleEnabledChange = async (ruleName, enabled) => {
try {
highlightRules.value[ruleName].enabled = enabled
// 保存到服务器
const config = {
customHighlightRules: serializeRules(highlightRules.value)
}
await $store.setTerminalSetting(config)
$message.success(`${ getRuleTitle(ruleName) }${ enabled ? '已启用' : '已禁用' }`)
} catch (error) {
$message.error('保存设置失败')
// 回滚状态
highlightRules.value[ruleName].enabled = !enabled
}
}
// 保存规则编辑
const handleRuleSave = async (ruleName, ruleData) => {
try {
highlightRules.value[ruleName] = { ...ruleData }
showRuleEdit.value = false
// 立即保存自定义规则到服务器
const config = {
customHighlightRules: serializeRules(highlightRules.value)
}
await $store.setTerminalSetting(config)
$message.success('规则保存成功')
} catch (error) {
$message.error('保存规则失败')
}
}
// 恢复默认规则
const handleRestoreDefaults = async () => {
try {
await $messageBox.confirm('确认恢复为默认高亮规则?此操作将覆盖所有自定义规则和颜色。', '确认', {
type: 'warning'
})
// 拷贝默认规则,确保包含所有新属性
highlightRules.value = createDefaultRules()
// 立即保存到服务器,清除自定义规则
const config = {
customHighlightRules: null
}
await $store.setTerminalSetting(config)
$message.success('已恢复默认规则和颜色')
} catch (error) {
if (error !== 'cancel') {
$message.error('恢复默认规则失败')
}
}
}
// 导出规则
const handleExportRules = () => {
const rulesData = {
highlightConfig: highlightConfig.value,
highlightRules: serializeRules(highlightRules.value),
exportTime: new Date().toISOString()
}
const blob = new Blob([JSON.stringify(rulesData, null, 2),], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `terminal-highlight-rules-${ new Date().toISOString().slice(0, 10) }.json`
a.click()
URL.revokeObjectURL(url)
$message.success('规则导出成功')
}
// 导入规则
const handleImportRules = () => {
showImportDialog.value = true
importRulesText.value = ''
}
const confirmImportRules = async () => {
try {
const importData = JSON.parse(importRulesText.value)
if (importData.highlightConfig) {
highlightConfig.value = { ...importData.highlightConfig }
}
if (importData.highlightRules) {
// 转换导入的规则,将序列化的pattern重新转换为RegExp对象
const convertedRules = {}
for (const [key, rule,] of Object.entries(importData.highlightRules)) {
convertedRules[key] = {
...rule,
// 将序列化的pattern转换回RegExp对象
pattern: rule.pattern && rule.pattern.source
? new RegExp(rule.pattern.source, rule.flags || 'gi')
: rule.pattern
}
}
highlightRules.value = convertedRules
}
// 保存到服务器时序列化RegExp
const config = {
keywordHighlight: highlightConfig.value.enabled,
highlightDebugMode: highlightConfig.value.debugMode,
customHighlightRules: serializeRules(highlightRules.value)
}
await $store.setTerminalSetting(config)
showImportDialog.value = false
$message.success('规则导入并保存成功')
} catch (error) {
$message.error('导入失败,请检查JSON格式')
}
}
// 实时保存配置
const handleConfigChange = async () => {
try {
// 立即更新到store
$store.terminalConfig.keywordHighlight = highlightConfig.value.enabled
$store.terminalConfig.highlightDebugMode = highlightConfig.value.debugMode
// 保存到服务器
const config = {
keywordHighlight: highlightConfig.value.enabled,
highlightDebugMode: highlightConfig.value.debugMode
}
await $store.setTerminalSetting(config)
} catch (error) {
$message.error('保存配置失败')
}
}
// 关闭对话框
const handleCancel = () => {
dialogVisible.value = false
}
// 监听对话框显示
watch(() => props.show, (show) => {
if (show) {
loadConfig()
}
})
</script>
<style lang="scss" scoped>
.debug-tip {
margin-left: 12px;
display: block;
margin-top: 4px;
}
.rule-actions {
display: flex;
gap: 8px;
.el-button {
margin-right: 0;
}
}
.rules-section {
margin-top: 20px;
.rule-item {
margin-bottom: 16px;
.rule-content {
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 12px;
.rule-enable {
flex-shrink: 0;
}
.color-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: 1;
}
.color-display {
display: flex;
align-items: center;
gap: 6px;
.color-label {
font-size: 12px;
color: var(--el-text-color-regular);
min-width: 24px;
}
}
.color-preview {
width: 16px;
height: 16px;
border-radius: 2px;
display: inline-block;
border: 1px solid var(--el-border-color);
}
.background-preview {
width: 16px;
height: 16px;
border-radius: 2px;
display: inline-block;
border: 1px solid var(--el-border-color);
}
.style-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.rule-buttons {
display: flex;
gap: 8px;
}
}
}
}
}
.test-panel {
width: calc(100% + 130px);
margin-left: -130px;
padding-left: 130px;
.test-input {
margin-bottom: 12px;
width: 100%;
}
:deep(.el-textarea__inner) {
width: 100%;
}
}
.test-output {
padding: 12px;
border-radius: 4px;
font-weight: normal;
font-style: normal;
min-height: 120px;
line-height: 1.5;
width: 100%;
overflow-wrap: break-word;
margin-top: 0;
}
.dialog_footer {
display: flex;
justify-content: center;
gap: 12px;
}
</style>
<style lang="scss">
.local_setting_drawer {
.el-drawer__header {
margin-bottom: 0 !important;
}
}
</style>
@@ -1,149 +1,113 @@
<template>
<el-drawer
v-model="visible"
title="本地设置"
title="基础设置"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
:modal="true"
modal-class="local_setting_drawer"
modal-class="terminal_setting_drawer"
:size="isMobileScreen ? '80%' : '30%'"
>
<el-tabs tab-position="top">
<el-tab-pane label="终端设置" lazy>
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="终端主题" prop="theme">
<el-select v-model="theme" placeholder="" style="width: 100%;">
<el-option
v-for="(value, key) in themeList"
:key="key"
:label="key"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="终端字体" prop="fontSize">
<el-input-number v-model="fontSize" :min="6" :max="30" />
</el-form-item>
<el-form-item label="终端背景" prop="backgroundImage">
<ul class="background_list">
<li :class="background ? '' : 'active'" @click="changeBackground('')">
<el-image class="image">
<template #error>
<div class="theme_background_text">
主题背景
</div>
</template>
</el-image>
</li>
<li
v-for="item in defaultBackgroundImages"
:key="item"
:class="background === item ? 'active' : ''"
:style="`background: ${item};`"
@click="changeBackground(item)"
/>
</ul>
<div class="custom_background">
<el-input
v-model="background"
clearable
placeholder="自定义背景图片url"
autocomplete="on"
/>
</div>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="快捷操作">
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="自动重连" prop="autoReconnect">
<PlusSupportTip>
<span>
<el-switch
v-model="autoReconnect"
class="swtich"
inline-prompt
:disabled="!isPlusActive"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
/>
<span class="plus_support_tip_text">(Plus专属功能)</span>
</span>
</PlusSupportTip>
</el-form-item>
<el-form-item label="脚本执行" prop="autoExecuteScript">
<el-tooltip
effect="dark"
content="启用后从脚本库选中脚本后自动执行(回车操作)"
placement="right"
>
<el-switch
v-model="autoExecuteScript"
class="swtich"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="自动"
inactive-text="手动"
/>
</el-tooltip>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="菜单选项">
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="实例" prop="instanceGroup">
<el-radio-group
v-model="hostGroupCascader"
size="small"
text-color="#fff"
fill="#13ce66"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="脚本库" prop="scriptLibrary">
<el-radio-group
v-model="scriptLibraryCascader"
size="small"
text-color="#fff"
:fill="scriptLibrary ? '#13ce66' : '#999'"
:disabled="!scriptLibrary"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
</el-radio-group>
<span class="script_library_switch">
<el-switch
v-model="scriptLibrary"
class="swtich"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999"
active-text="显示"
inactive-text="隐藏"
/>
</span>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<el-form
ref="formRef"
label-suffix=":"
label-width="100px"
:show-message="false"
>
<el-form-item label="终端主题" prop="theme">
<el-select v-model="theme" placeholder="" style="width: 100%;">
<el-option
v-for="(value, key) in themeList"
:key="key"
:label="key"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="终端字体" prop="fontFamily">
<el-select v-model="fontFamily" placeholder="选择字体" style="width: 100%;">
<el-option
v-for="font in commonFonts"
:key="font.value"
:label="font.label"
:value="font.value"
/>
</el-select>
</el-form-item>
<el-form-item label="字体大小" prop="fontSize">
<el-input-number v-model="fontSize" :min="6" :max="30" />
</el-form-item>
<el-form-item label="字体颜色" prop="fontColor">
<div class="font_color_list">
<div :class="['color_item', 'theme_color', { 'active': !fontColor }]" @click="setThemeColor">
<span class="color_text">主题颜色</span>
</div>
<div :class="['color_item', 'color_picker_wrapper', { 'active': !!fontColor }]">
<el-color-picker
v-model="customFontColor"
show-alpha
:predefine="predefineColors"
@change="handleColorChange"
/>
</div>
</div>
</el-form-item>
<el-form-item label="光标颜色" prop="cursorColor">
<div class="font_color_list">
<div :class="['color_item', 'theme_color', { 'active': !cursorColor }]" @click="setThemeCursorColor">
<span class="color_text">主题颜色</span>
</div>
<div :class="['color_item', 'color_picker_wrapper', { 'active': !!cursorColor }]">
<el-color-picker
v-model="customCursorColor"
show-alpha
:predefine="predefineColors"
@change="handleCursorColorChange"
/>
</div>
</div>
</el-form-item>
<el-form-item label="选中颜色" prop="selectionColor">
<div class="font_color_list">
<div class="color_item color_picker_wrapper active">
<el-color-picker
v-model="selectionColor"
show-alpha
:predefine="predefineColors"
/>
</div>
</div>
</el-form-item>
<el-form-item label="终端背景" prop="backgroundImage">
<ul class="background_list">
<li :class="background ? '' : 'active'" @click="changeBackground('')">
<el-image class="image">
<template #error>
<div class="theme_background_text">
主题背景
</div>
</template>
</el-image>
</li>
<li
v-for="item in defaultBackgroundImages"
:key="item"
:class="background === item ? 'active' : ''"
:style="`background: ${item};`"
@click="changeBackground(item)"
/>
</ul>
<div class="custom_background">
<el-input
v-model="background"
clearable
placeholder="自定义背景图片url"
autocomplete="on"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
@@ -153,11 +117,12 @@
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { computed, getCurrentInstance, ref } from 'vue'
import themeList from 'xterm-theme'
import useMobileWidth from '@/composables/useMobileWidth'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
show: {
@@ -165,48 +130,131 @@ const props = defineProps({
type: Boolean
}
})
const emit = defineEmits(['update:show',])
const { isMobileScreen } = useMobileWidth()
const emit = defineEmits(['update:show'])
// 全平台通用字体列表
const commonFonts = [
{ label: 'monospace', value: 'monospace' },
{ label: 'Courier New', value: 'Courier New, Courier, monospace' },
{ label: 'Courier', value: 'Courier, monospace' },
{ label: 'Consolas', value: 'Consolas, monospace' },
{ label: 'Monaco', value: 'Monaco, monospace' },
{ label: 'Menlo', value: 'Menlo, monospace' },
{ label: 'Cascadia Code', value: 'Cascadia Code, monospace' }
]
// 预定义颜色
const predefineColors = [
'#ffffff',
'#00ff41',
'#00ffff',
'#ff00ff',
'#ffff00',
'#ff0000',
'#00ff00',
'#0000ff',
'#000000'
]
// 保存最后选择的自定义颜色
const lastCustomColor = ref('#ffffff')
const lastCustomCursorColor = ref('#00ff41')
const defaultBackgroundImages = computed(() => $store.defaultBackgroundImages)
const visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
const theme = computed({
get: () => $store.terminalConfig.themeName,
set: (newVal) => $store.setTerminalSetting({ themeName: newVal })
})
const background = computed({
get: () => $store.terminalConfig.background,
set: (newVal) => $store.setTerminalSetting({ background: newVal })
})
const fontSize = computed({
get: () => $store.terminalConfig.fontSize,
set: (newVal) => $store.setTerminalSetting({ fontSize: newVal })
})
const autoReconnect = computed({
get: () => $store.terminalConfig.autoReconnect,
set: (newVal) => $store.setTerminalSetting({ autoReconnect: newVal })
const fontFamily = computed({
get: () => $store.terminalConfig.fontFamily || 'monospace',
set: (newVal) => $store.setTerminalSetting({ fontFamily: newVal })
})
const autoExecuteScript = computed({
get: () => $store.terminalConfig.autoExecuteScript,
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
const fontColor = computed({
get: () => $store.terminalConfig.fontColor,
set: (newVal) => $store.setTerminalSetting({ fontColor: newVal })
})
const scriptLibrary = computed({
get: () => $store.menuSetting.scriptLibrary,
set: (newVal) => $store.setMenuSetting({ scriptLibrary: newVal })
const customFontColor = computed({
get: () => {
// 如果有自定义颜色,显示自定义颜色;否则显示上次选择的颜色
if (fontColor.value) {
lastCustomColor.value = fontColor.value
return fontColor.value
}
return lastCustomColor.value
},
set: (newVal) => {
lastCustomColor.value = newVal
fontColor.value = newVal
}
})
const scriptLibraryCascader = computed({
get: () => $store.menuSetting.scriptLibraryCascader,
set: (newVal) => $store.setMenuSetting({ scriptLibraryCascader: newVal })
const setThemeColor = () => {
fontColor.value = ''
}
const setCustomColor = (color) => {
fontColor.value = color
}
const handleColorChange = (color) => {
if (color) {
fontColor.value = color
}
}
const cursorColor = computed({
get: () => $store.terminalConfig.cursorColor,
set: (newVal) => $store.setTerminalSetting({ cursorColor: newVal })
})
const hostGroupCascader = computed({
get: () => $store.menuSetting.hostGroupCascader,
set: (newVal) => $store.setMenuSetting({ hostGroupCascader: newVal })
const customCursorColor = computed({
get: () => {
if (cursorColor.value) {
lastCustomCursorColor.value = cursorColor.value
return cursorColor.value
}
return lastCustomCursorColor.value
},
set: (newVal) => {
lastCustomCursorColor.value = newVal
cursorColor.value = newVal
}
})
const setThemeCursorColor = () => {
cursorColor.value = ''
}
const handleCursorColorChange = (color) => {
if (color) {
cursorColor.value = color
}
}
const selectionColor = computed({
get: () => $store.terminalConfig.selectionColor,
set: (newVal) => $store.setTerminalSetting({ selectionColor: newVal })
})
const isPlusActive = computed(() => $store.isPlusActive)
const changeBackground = (item) => {
background.value = item || ''
@@ -214,12 +262,77 @@ const changeBackground = (item) => {
</script>
<style lang="scss" scoped>
.font_color_list {
display: flex;
align-items: center;
gap: 8px;
.color_item {
height: 30px;
border-radius: 3px;
box-sizing: border-box;
cursor: pointer;
border: 1px solid var(--el-border-color);
display: flex;
align-items: center;
justify-content: center;
&:hover {
box-shadow: 0 0 5px #1890ff;
}
&.active {
box-shadow: 0 0 5px #1890ff;
border: 1px solid #1890ff;
}
&.theme_color {
width: 90px;
background: var(--el-fill-color-light);
color: var(--el-text-color-placeholder);
.color_text {
font-size: 11px;
}
}
&.color_picker_wrapper {
width: 30px;
padding: 0;
border: none;
box-shadow: none;
background: transparent;
&:hover {
box-shadow: none;
}
:deep(.el-color-picker__trigger) {
width: 30px;
height: 30px;
border-radius: 3px;
border: 1px solid var(--el-border-color);
padding: 0;
&:hover {
box-shadow: 0 0 5px #1890ff;
}
}
&.active :deep(.el-color-picker__trigger) {
box-shadow: 0 0 5px #1890ff;
border: 1px solid #1890ff;
}
}
}
}
.background_list {
display: flex;
flex-wrap: wrap;
li {
width: 126px;
height: 75px;
width: 90px;
height: 55px;
box-sizing: border-box;
border-radius: 3px;
margin: 0 5px 5px 0;
@@ -244,6 +357,7 @@ const changeBackground = (item) => {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
}
}
@@ -254,19 +368,12 @@ const changeBackground = (item) => {
display: flex;
justify-content: center;
}
.script_library_switch {
margin-left: 20px;
}
</style>
<style lang="scss">
.local_setting_drawer {
.terminal_setting_drawer {
.el-drawer__header {
margin-bottom: 0 !important;
}
}
.plus_support_tip_text {
margin-left: 5px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -69,6 +69,25 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown
trigger="click"
:teleported="isMobileScreen"
>
<span class="link_text">终端设置<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showSetting = true">
<span>基础设置</span>
</el-dropdown-item>
<el-dropdown-item @click="showHighlightSettings = true">
<span>高亮设置</span>
</el-dropdown-item>
<el-dropdown-item @click="showOtherSettings = true">
<span>其他设置</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown
trigger="click"
:teleported="isMobileScreen"
@@ -79,8 +98,8 @@
<el-dropdown-item @click="handleFullScreen">
<span>全屏</span>
</el-dropdown-item>
<el-dropdown-item @click="showSetting = true">
<span>本地设置</span>
<el-dropdown-item @click="showMenuOptions = true">
<span>菜单选项</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -95,7 +114,7 @@
>
<el-switch
v-model="isSyncAllSession"
class="swtich"
class="switch"
inline-prompt
style="
--el-switch-on-color: #13ce66;
@@ -114,7 +133,7 @@
>
<el-switch
v-model="showFooterBar"
class="swtich"
class="switch"
inline-prompt
style="
--el-switch-on-color: #13ce66;
@@ -134,7 +153,7 @@
>
<el-switch
v-model="isSingleWindowMode"
class="swtich"
class="switch"
inline-prompt
style="
--el-switch-on-color: #409eff;
@@ -340,7 +359,19 @@
@exec-script="handleExecScript"
/>
</el-drawer>
<div v-else :class="['tab_content_main_sftp', { 'show_sftp': showSftpSide }]">
<div
v-else
:class="['tab_content_main_sftp', { 'show_sftp': showSftpSide }]"
:style="showSftpSide ? { width: sftpWidth + 'px', minWidth: sftpWidth + 'px', maxWidth: sftpWidth + 'px' } : {}"
>
<!-- 拖拽调整条 -->
<div
v-if="showSftpSide"
class="sftp_resize_handle"
@mousedown="startResizeSftp"
>
<div class="sftp_resize_handle_line" />
</div>
<SftpV2
:init-connect="showSftpSide"
:host-id="item.id"
@@ -382,6 +413,9 @@
/>
<TerminalSetting v-model:show="showSetting" />
<TerminalHighlightSettings v-model:show="showHighlightSettings" />
<OtherSettings v-model:show="showOtherSettings" />
<MenuOptions v-model:show="showMenuOptions" />
</div>
</template>
@@ -403,6 +437,9 @@ import Terminal from './terminal.vue'
import ServerStatus from './server-status.vue'
import HostForm from '../../server/components/host-form.vue'
import TerminalSetting from './terminal-setting.vue'
import TerminalHighlightSettings from './terminal-highlight-settings.vue'
import OtherSettings from './other-settings.vue'
import MenuOptions from './menu-options.vue'
import FooterBar from './footer-bar.vue'
import SftpV2 from './sftp-v2.vue'
import TerminalSingleWindow from './terminal-single-window.vue'
@@ -438,10 +475,14 @@ watch(layoutMode, (newMode) => {
const hostFormVisible = ref(false)
const updateHostData = ref(null)
const showSetting = ref(false)
const showHighlightSettings = ref(false)
const showOtherSettings = ref(false)
const showMenuOptions = ref(false)
const showInfoSide = ref(isMobileScreen.value ? false : localStorage.getItem('showInfoSide') !== 'false')
const showSftpSide = ref(isMobileScreen.value ? false : localStorage.getItem('showSftpSide') !== 'false')
const showFooterBar = ref(localStorage.getItem('showFooterBar') === 'true')
const footerBarHeight = ref(parseInt(localStorage.getItem('footerBarHeight')) || 250)
const sftpWidth = ref(parseInt(localStorage.getItem(SFTP_WIDTH_KEY)) || 450)
const longPressCtrl = ref(false)
const longPressAlt = ref(false)
const scriptDropdownRef = ref(null)
@@ -553,6 +594,44 @@ const handleFooterBarHeightChange = (height) => {
debouncedResizeTerminal()
}
// SFTP宽度调整相关
const SFTP_WIDTH_KEY = 'easynode_sftp_width'
const isResizingSftp = ref(false)
const startX = ref(0)
const startWidth = ref(0)
const startResizeSftp = (e) => {
isResizingSftp.value = true
startX.value = e.clientX
startWidth.value = sftpWidth.value
document.addEventListener('mousemove', handleResizeSftp)
document.addEventListener('mouseup', stopResizeSftp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
e.preventDefault()
}
const handleResizeSftp = (e) => {
if (!isResizingSftp.value) return
const deltaX = startX.value - e.clientX // 向左拖拽为正值(增大宽度)
const newWidth = Math.max(200, Math.min(800, startWidth.value + deltaX)) // 最小200px,最大800px
sftpWidth.value = newWidth
}
const stopResizeSftp = () => {
isResizingSftp.value = false
document.removeEventListener('mousemove', handleResizeSftp)
document.removeEventListener('mouseup', stopResizeSftp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
// 保存宽度到 localStorage
localStorage.setItem(SFTP_WIDTH_KEY, sftpWidth.value.toString())
debouncedResizeTerminal()
}
const getStartIndexByTabIndex = (idx) => {
let start = 0
for (let i = 0; i < idx; i++) {
@@ -714,28 +793,32 @@ const handleLinkHost = (hostDescObj) => {
const handleExecScript = async (scriptDescObj) => {
if (!scriptDescObj) return // clearCheckedNodes二次触发change事件
let command = ''
let useBase64 = false
const id = Array.isArray(scriptDescObj) ? scriptDescObj.slice(-1)[0] : scriptDescObj?.id
if (id) {
const script = scriptList.value.find((item) => item.id === id)
command = script?.command
useBase64 = script?.useBase64 || false // 读取脚本的base64配置
} else {
command = scriptDescObj
useBase64 = false // 外部传入的命令默认不用base64
}
if (!command) return $message.warning('未找到对应的脚本')
if (!isSyncAllSession.value) {
// 不同步时,使用 handleInputCommand(会处理分屏同步)
await handleInputCommand(command, 'script')
await handleInputCommand(command, 'script', useBase64)
} else {
// 同步输入到所有终端
if (isSingleWindowMode.value) {
// 单窗口模式:inputCommand 方法内部会处理同步逻辑
await $nextTick()
singleWindowRef.value?.inputCommandToTerminal(command, 'script')
singleWindowRef.value?.inputCommandToTerminal(command, 'script', useBase64)
} else {
// 多窗口模式:遍历所有终端引用
terminalRefs.value.forEach((terminalRef) => {
terminalRef.inputCommand(command, 'script')
terminalRef.inputCommand(command, 'script', useBase64)
})
}
}
@@ -874,11 +957,11 @@ const resizeTerminal = () => {
}
}
const handleInputCommand = async (command, type = 'input') => {
const handleInputCommand = async (command, type = 'input', useBase64 = false) => {
if (isSingleWindowMode.value) {
// 单窗口模式下,使用 singleWindowRef 来执行命令
await $nextTick()
singleWindowRef.value?.inputCommandToTerminal(command, type)
singleWindowRef.value?.inputCommandToTerminal(command, type, useBase64)
} else {
// 多窗口模式下,优先使用当前聚焦的终端
let targetTerminalRef = null
@@ -895,7 +978,7 @@ const handleInputCommand = async (command, type = 'input') => {
await $nextTick()
targetTerminalRef?.focusTab()
targetTerminalRef?.inputCommand(command, type)
targetTerminalRef?.inputCommand(command, type, useBase64)
// 处理分屏同步逻辑
const curTabKey = getTabKeyByIndex(activeTabIndex.value)
@@ -908,7 +991,7 @@ const handleInputCommand = async (command, type = 'input') => {
tabTerminalRefs.forEach((terminalRef) => {
if (terminalRef?.$?.uid !== targetUid) {
terminalRef.inputCommand(command, type)
terminalRef.inputCommand(command, type, useBase64)
}
})
}
@@ -967,6 +1050,8 @@ onMounted(() => {
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', fullScreenCb)
document.removeEventListener('mousemove', handleResizeSftp)
document.removeEventListener('mouseup', stopResizeSftp)
})
</script>
@@ -1208,13 +1293,37 @@ onUnmounted(() => {
overflow: hidden;
transition: all 0.2s;
flex-shrink: 0;
position: relative;
&.show_sftp {
width: 450px;
min-width: 450px;
max-width: 450px;
overflow-y: auto;
overflow-x: hidden;
}
.sftp_resize_handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8px;
cursor: ew-resize;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
&:hover .sftp_resize_handle_line {
opacity: 1;
}
.sftp_resize_handle_line {
width: 2px;
height: 40px;
background: var(--el-color-primary);
border-radius: 1px;
opacity: 0;
transition: opacity 0.2s;
}
}
}
}
+148 -44
View File
@@ -28,6 +28,7 @@ import { terminalStatus } from '@/utils/enum'
import { useContextMenu } from '@/composables/useContextMenu'
import { EventBus, isDockerId, isDockerComposeYml, generateSocketInstance } from '@/utils'
import useMobileWidth from '@/composables/useMobileWidth'
import { TerminalHighlighter } from '@/utils/highlighter'
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
@@ -62,6 +63,8 @@ const emit = defineEmits(['inputCommand', 'cdCommand', 'ping-data', 'reset-long-
const socket = ref(null)
// const commandHistoryList = ref([])
const term = ref(null)
const terminalText = ref('')
const highlighter = ref(null)
const initCommand = ref('')
const timer = ref(null)
const pingTimer = ref(null)
@@ -71,13 +74,13 @@ const socketConnected = ref(false)
const curStatus = ref(CONNECTING)
const terminalRef = ref(null)
const { showMenu, closeMenu, isVisible } = useContextMenu()
const cursorTheme = {
cursor: '#00ff41',
cursorAccent: '#000000'
}
const theme = computed(() => themeList[$store.terminalConfig.themeName])
const fontSize = computed(() => $store.terminalConfig.fontSize)
const fontFamily = computed(() => $store.terminalConfig.fontFamily)
const fontColor = computed(() => $store.terminalConfig.fontColor)
const cursorColor = computed(() => $store.terminalConfig.cursorColor)
const selectionColor = computed(() => $store.terminalConfig.selectionColor)
const background = computed(() => $store.terminalConfig.background)
const hostObj = computed(() => props.hostObj)
const hostId = computed(() => hostObj.value.id)
@@ -86,31 +89,78 @@ const menuCollapse = computed(() => $store.menuCollapse)
const menuPosition = computed(() => $store.menuPosition)
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
const autoReconnect = computed(() => $store.terminalConfig.autoReconnect)
const keywordHighlight = computed(() => $store.terminalConfig.keywordHighlight)
const customHighlightRules = computed(() => $store.terminalConfig.customHighlightRules)
const highlightDebugMode = computed(() => $store.terminalConfig.highlightDebugMode)
const isPlusActive = computed(() => $store.isPlusActive)
const isLongPressCtrl = computed(() => props.longPressCtrl)
const isLongPressAlt = computed(() => props.longPressAlt)
// 应用终端主题的公共函数
const applyTerminalTheme = () => {
if (!term.value || !terminalRef.value) return
// 构建光标主题
const cursorTheme = {
cursor: cursorColor.value || theme.value.cursor,
cursorAccent: '#000000'
}
// 构建完整主题
const customTheme = {
...theme.value,
...cursorTheme
}
// 应用自定义字体颜色
if (fontColor.value) {
customTheme.foreground = fontColor.value
}
// 应用自定义选中颜色
if (selectionColor.value) {
customTheme.selectionBackground = selectionColor.value
}
// 根据背景设置应用主题
if (background.value) {
// 有自定义背景:xterm背景透明,使用terminalRef显示背景
term.value.options.theme = { ...customTheme, background: 'transparent' }
terminalRef.value.style.backgroundImage = background.value?.startsWith('http')
? `url(${ background.value })`
: `${ background.value }`
terminalRef.value.style.backgroundColor = ''
// 设置 viewport 为透明以显示自定义背景
const viewport = terminalRef.value.querySelector('.xterm-viewport')
if (viewport) {
viewport.style.setProperty('background-color', 'transparent', 'important')
}
} else {
// 使用主题背景:清除terminalRef背景,使用xterm主题背景色
term.value.options.theme = customTheme
terminalRef.value.style.backgroundImage = ''
terminalRef.value.style.backgroundColor = ''
// 设置 viewport 背景色为主题背景色
const viewport = terminalRef.value.querySelector('.xterm-viewport')
if (viewport) {
viewport.style.setProperty('background-color', customTheme.background || '#1e1e1e', 'important')
}
}
}
watch([menuCollapse, menuPosition,], () => {
nextTick(() => {
handleResize()
})
})
watch(theme, () => {
nextTick(() => {
// 自定义光标颜色
const customTheme = {
...theme.value,
...cursorTheme
}
if (!background.value) {
term.value.options.theme = customTheme
} else {
term.value.options.theme = { ...customTheme, background: '#00000080' }
}
})
})
watch(theme, () => nextTick(applyTerminalTheme))
watch(fontColor, () => nextTick(applyTerminalTheme))
watch(cursorColor, () => nextTick(applyTerminalTheme))
watch(selectionColor, () => nextTick(applyTerminalTheme))
watch(background, () => nextTick(applyTerminalTheme), { immediate: true })
watch(fontSize, () => {
nextTick(() => {
@@ -119,23 +169,35 @@ watch(fontSize, () => {
})
})
watch(background, (newVal) => {
watch(fontFamily, () => {
nextTick(() => {
const customTheme = {
...theme.value,
...cursorTheme
}
if (newVal) {
term.value.options.theme = { ...customTheme, background: '#00000080' }
terminalRef.value.style.backgroundImage = background.value?.startsWith('http') ? `url(${ background.value })` : `${ background.value }`
// terminalRef.value.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15)), url(${ background.value })`
} else {
term.value.options.theme = customTheme
terminalRef.value.style.backgroundImage = null
}
term.value.options.fontFamily = fontFamily.value
handleResize()
})
}, { immediate: true })
})
watch(keywordHighlight, (newVal) => {
if (highlighter.value) {
highlighter.value.setEnabled(newVal)
}
})
// 监听自定义高亮规则变化
watch(customHighlightRules, (newRules) => {
if (highlighter.value) {
if (highlightDebugMode.value) {
console.log('自定义高亮规则变化,更新高亮器:', newRules)
}
highlighter.value.updateCustomRules(newRules)
}
}, { deep: true, immediate: true })
// 监听调试模式变化
watch(highlightDebugMode, (newVal) => {
if (highlighter.value) {
highlighter.value.setDebugMode(newVal)
}
})
watch(curStatus, () => {
console.warn(`status: ${ curStatus.value }`)
@@ -162,7 +224,15 @@ const connectIO = () => {
socket.value.on('terminal_connect_success', () => {
socket.value.on('output', (str) => {
if (props.isSingleWindow && !isPlusActive.value) return
term.value.write(str)
// 使用高亮器处理
if (keywordHighlight.value && highlighter.value) {
const highlightedStr = highlighter.value.highlightText(str)
term.value.write(highlightedStr)
} else {
term.value.write(str)
}
terminalText.value += str
})
socket.value.on('terminal_connect_shell_success', () => {
@@ -257,7 +327,7 @@ const reconnectTerminal = (isCommonTips = false, tips) => {
term.value.write(`\r\n\x1b[91m${ tips },自动重连中...\x1b[0m \r\n`)
connectIO()
} else {
term.value.write(`\r\n\x1b[91mError: ${ tips },请重新连接。([功能项->本地设置->快捷操作]中开启自动重连)\x1b[0m \r\n`)
term.value.write(`\r\n\x1b[91mError: ${ tips },请重新连接。([终端设置->其他设置]中开启自动重连)\x1b[0m \r\n`)
}
} else {
term.value.write(`\n${ tips } \n`)
@@ -271,9 +341,9 @@ const createLocalTerminal = () => {
convertEol: true,
cursorBlink: true,
disableStdin: false,
minimumContrastRatio: 7,
minimumContrastRatio: 1, // 无对比度要求
allowTransparency: true,
fontFamily: 'Cascadia Code, Menlo, monospace',
fontFamily: fontFamily.value,
fontSize: fontSize.value,
theme: theme.value,
scrollback: 5000, // 滚动缓冲区大小
@@ -296,6 +366,14 @@ const createLocalTerminal = () => {
term.value = terminalInstance
terminalInstance.open(terminalRef.value)
!isMobileScreen.value && terminalInstance.loadAddon(canvasAddon)
// 初始化TerminalHighlighter
highlighter.value = new TerminalHighlighter(terminalInstance, {
enabled: keywordHighlight.value,
debugMode: highlightDebugMode.value || false,
customRules: customHighlightRules.value
})
terminalInstance.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
if (props.autoFocus) {
@@ -305,6 +383,11 @@ const createLocalTerminal = () => {
onFindText()
onWebLinks()
onResize()
// 初始化主题设置
nextTick(() => {
applyTerminalTheme()
})
}
const shellResize = () => {
@@ -351,7 +434,6 @@ const onFindText = () => {
// term.value.loadAddon(searchBar.value)
}
const terminalText = ref(null)
const enterTimer = ref(null)
function filterAnsiSequences(str) {
@@ -673,19 +755,40 @@ const focusTab = () => {
}, 200)
}
const inputCommand = (command, type = 'input') => {
const inputCommand = (command, type = 'input', useBase64 = false) => {
if (type === 'script') {
command = command + (autoExecuteScript.value ? '\n' : '')
if (useBase64) {
// 使用Base64管道模式执行
// 处理 UTF-8 字符:使用现代浏览器的 TextEncoder API
const utf8Bytes = new TextEncoder().encode(command)
const encodedScript = btoa(String.fromCharCode(...utf8Bytes))
command = `echo '${ encodedScript }' | base64 -d | bash${ autoExecuteScript.value ? '\n' : '' }`
} else {
// 直接执行模式:根据配置添加换行符
command = command + (autoExecuteScript.value ? '\n' : '')
}
}
socket.value.emit('input', command)
}
const execExternalCommand = (command) => {
const execExternalCommand = (command, useBase64 = false) => {
if (!socket.value || !socket.value.connected || curStatus.value !== CONNECT_SUCCESS) {
$message.error('终端连接已断开,无法执行指令')
return
}
socket.value.emit('input', command + '\n')
if (useBase64) {
// 使用Base64管道模式执行
// 处理 UTF-8 字符:使用现代浏览器的 TextEncoder API
const utf8Bytes = new TextEncoder().encode(command)
const encodedScript = btoa(String.fromCharCode(...utf8Bytes))
command = `echo '${ encodedScript }' | base64 -d | bash${ autoExecuteScript.value ? '\n' : '' }`
} else {
// 直接执行模式:根据配置添加换行符
command = command + (autoExecuteScript.value ? '\n' : '')
}
socket.value.emit('input', command)
term.value?.focus()
}
@@ -721,13 +824,14 @@ defineExpose({
.terminal_container {
background-size: 100% 100%;
background-repeat: no-repeat;
// padding: 10px;
:deep(.xterm) {
height: 100%;
padding: 8px 8px;
}
:deep(.xterm-viewport) {
overflow-y: auto;
background-color: transparent !important;
}
:deep(.xterm-screen) {