feat: web i18n

This commit is contained in:
chaos-zhu
2026-04-01 20:31:06 +08:00
parent 3e2f60ea48
commit 3ffbea54e0
75 changed files with 4519 additions and 1789 deletions
+1
View File
@@ -47,6 +47,7 @@
"socket.io-client": "^4.8.1",
"vue": "^3.5.13",
"vue-codemirror": "^6.1.1",
"vue-i18n": "11",
"vue-router": "^4.5.0",
"xterm-theme": "^1.1.0"
},
+13 -3
View File
@@ -1,15 +1,25 @@
<template>
<el-config-provider :locale="locale">
<el-config-provider :locale="elementLocale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { ref, getCurrentInstance } from 'vue'
import en from 'element-plus/es/locale/lang/en'
const { proxy: { $store } } = getCurrentInstance()
const { locale } = useI18n()
const elementLocaleMap = {
'zh-CN': zhCn,
en
}
const elementLocale = computed(() => elementLocaleMap[locale.value] || zhCn)
const locale = ref(zhCn)
$store.setDefaultTheme()
</script>
+33 -31
View File
@@ -2,7 +2,7 @@
<el-dialog
v-model="visible"
width="600px"
title="AI API配置"
:title="t('aiChat.configTitle')"
:top="isMobile() ? '45px' : '10vh'"
:close-on-click-modal="false"
:close-on-press-escape="false"
@@ -17,7 +17,7 @@
label-width="80px"
class="config_form"
>
<el-form-item label="接口地址" prop="apiUrl">
<el-form-item :label="t('aiChat.apiUrl')" prop="apiUrl">
<el-autocomplete
v-model.trim="AIconfigFormData.apiUrl"
:fetch-suggestions="inputApiUrlSuggestion"
@@ -29,10 +29,10 @@
</template>
</el-autocomplete>
</el-form-item>
<el-form-item label="API KEY" prop="apiKey">
<el-input v-model="AIconfigFormData.apiKey" clearable placeholder="例如: sk-xxx" />
<el-form-item :label="t('aiChat.apiKey')" prop="apiKey">
<el-input v-model="AIconfigFormData.apiKey" clearable :placeholder="t('aiChat.apiKeyPlaceholder')" />
</el-form-item>
<el-form-item label="模型列表" prop="models">
<el-form-item :label="t('aiChat.modelList')" prop="models">
<div class="models_input_wrap">
<el-input-tag
v-model="AIconfigFormData.models"
@@ -42,11 +42,11 @@
clearable
@change="handleModelsChange"
/>
<el-button type="primary" :loading="fetchingModels" @click="handleFetchModels">获取模型</el-button>
<el-button type="primary" :loading="fetchingModels" @click="handleFetchModels">{{ t('aiChat.fetchModels') }}</el-button>
</div>
</el-form-item>
<el-form-item label="标题生成" prop="titleGenMedel">
<el-select v-model="AIconfigFormData.titleGenMedel" clearable placeholder="请选择标题生成模型">
<el-form-item :label="t('aiChat.titleGeneration')" prop="titleGenMedel">
<el-select v-model="AIconfigFormData.titleGenMedel" clearable :placeholder="t('aiChat.selectTitleModel')">
<el-option
v-for="(item, index) in AIconfigFormData.models"
:key="index"
@@ -56,19 +56,19 @@
</el-select>
</el-form-item>
</el-form>
<el-alert title="提示:" type="warning" :closable="false">
<el-alert :title="t('aiChat.alertTitle')" type="warning" :closable="false">
<div class="ai_config_alert_content">
<p>1. 接口地址需填写完整,例如:<span class="ai_config_alert_content_span">https://api.openai.com/v1/chat/completions</span></p>
<p>2. 获取模型列表只支持通过<span class="ai_config_alert_content_span">https://{host}/v1/models API</span>获取</p>
<p>3. 模型对话在web端进行,因此需供应方支持<span class="ai_config_alert_content_span">cors跨域</span>, 例如阿里通义API<span class="ai_config_alert_content_span">不支持</span></p>
<p>4. 多渠道建议使用<a class="ai_config_alert_content_span one_api_link" href="https://github.com/songquanpeng/one-api" target="_blank">one-api</a>等专业AI平台聚合服务</p>
<p>{{ t('aiChat.alert1') }}<span class="ai_config_alert_content_span">https://api.openai.com/v1/chat/completions</span></p>
<p>{{ t('aiChat.alert2Prefix') }}<span class="ai_config_alert_content_span">https://{host}/v1/models API</span>{{ t('aiChat.alert2Suffix') }}</p>
<p>{{ t('aiChat.alert3Prefix') }}<span class="ai_config_alert_content_span">{{ t('aiChat.alert3Middle') }}</span>{{ t('aiChat.alert3Suffix') }}</p>
<p>{{ t('aiChat.alert4Prefix') }}<a class="ai_config_alert_content_span one_api_link" href="https://github.com/songquanpeng/one-api" target="_blank">one-api</a>{{ t('aiChat.alert4Suffix') }}</p>
</div>
</el-alert>
<template #footer>
<footer>
<div class="footer_btns">
<el-button type="info" @click="handleClose">关闭</el-button>
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
<el-button type="info" @click="handleClose">{{ t('common.close') }}</el-button>
<el-button type="primary" :loading="loading" @click="handleSave">{{ t('common.save') }}</el-button>
</div>
</footer>
</template>
@@ -77,9 +77,11 @@
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { isMobile } from '@/utils'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -122,22 +124,22 @@ watch(props.aiConfig, (newVal) => {
}, { immediate: true })
const rules = {
apiUrl: [
{ required: true, message: '请输入接口地址', trigger: 'change' },
{
pattern: /^https?:\/\/.+/,
message: '请输入以http或https开头的有效URL',
trigger: 'change'
},
],
apiKey: [{ required: true, message: '请输入API KEY', trigger: 'change' },],
models: [{ required: true, message: '请输入或获取模型列表', trigger: 'change' },],
titleGenMedel: [{ required: true, message: '请选择标题生成模型', trigger: 'change' },]
apiUrl: [
{ required: true, message: t('aiChat.validation.apiUrlRequired'), trigger: 'change' },
{
pattern: /^https?:\/\/.+/,
message: t('aiChat.validation.apiUrlInvalid'),
trigger: 'change'
},
],
apiKey: [{ required: true, message: t('aiChat.validation.apiKeyRequired'), trigger: 'change' },],
models: [{ required: true, message: t('aiChat.validation.modelListRequired'), trigger: 'change' },],
titleGenMedel: [{ required: true, message: t('aiChat.validation.titleModelRequired'), trigger: 'change' },]
}
const handleFetchModels = async () => {
if (!AIconfigFormData.value.apiUrl || !AIconfigFormData.value.apiKey) {
$message.warning('请先填写接口地址和API KEY')
$message.warning(t('aiChat.fillApiUrlAndKeyFirst'))
return
}
fetchingModels.value = true
@@ -149,12 +151,12 @@ const handleFetchModels = async () => {
console.log('models: ', data)
if (Array.isArray(data)) {
AIconfigFormData.value.models = data.map(item => item.id).filter(item => item)
$message.success('获取模型列表成功')
$message.success(t('aiChat.fetchModelsSuccess'))
} else {
$message.error(`获取模型列表失败:${ data.msg }`)
$message.error(t('aiChat.fetchModelsFailed', { message: data.msg }))
}
} catch (error) {
$message.error(`获取模型列表失败:${ error.message }`)
$message.error(t('aiChat.fetchModelsFailed', { message: error.message }))
} finally {
fetchingModels.value = false
}
@@ -167,7 +169,7 @@ const handleSave = async () => {
loading.value = true
await $api.saveAIConfig(AIconfigFormData.value)
await $store.getAIConfig()
$message.success('保存成功')
$message.success(t('aiChat.saveSuccess'))
visible.value = false
} catch (error) {
console.error(error)
+3 -3
View File
@@ -3,7 +3,7 @@
<PlusSupportTip>
<div class="ai_button" @click="handleClick">
<img src="@/assets/image/coding32.png" alt="AIHelper">
AI Chat
{{ t('plusTable.plusFeatures.aiChat') }}
</div>
</PlusSupportTip>
<AiChat v-model:visible="visible" />
@@ -12,13 +12,13 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AiChat from '@/components/ai-chat/index.vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const visible = ref(false)
// const emit = defineEmits(['click']);
const { t } = useI18n()
const handleClick = () => {
// emit('click');
visible.value = true
}
</script>
@@ -6,17 +6,17 @@
<div class="code_header_actions">
<span
class="code_action_btn code_copy_btn"
title="复制代码"
:title="t('common.copy')"
@click="handleCopy"
>
复制
{{ t('common.copy') }}
</span>
<span
class="code_action_btn code_exec_btn"
title="在终端执行"
:title="t('terminal.sendToTerminal')"
@click="handleExec"
>
执行
{{ t('terminal.execute') }}
</span>
</div>
</div>
@@ -29,6 +29,7 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { EventBus } from '@/utils'
import { ElMessage } from 'element-plus'
import hljs from 'highlight.js'
@@ -61,6 +62,7 @@ const props = defineProps({
import useStore from '@/store'
const $store = useStore()
const isDark = computed(() => $store.isDark)
const { t } = useI18n()
// 获取语言标签
const languageLabel = computed(() => {
@@ -114,7 +116,7 @@ const handleCopy = () => {
const handleExec = () => {
const code = (props.node?.code || props.node?.value || '').trim()
if (!code) {
ElMessage.warning('代码内容为空')
ElMessage.warning(t('common.emptyCodeContent'))
return
}
// 通过 EventBus 发送执行命令
@@ -226,4 +228,4 @@ const handleExec = () => {
}
}
}
</style>
</style>
+20 -18
View File
@@ -34,7 +34,7 @@
<div class="right_wrap">
<el-icon
class="right_wrap_icon"
title="新建对话"
:title="t('aiChat.newChat')"
@click="handleNewChat"
>
<Plus />
@@ -46,7 +46,7 @@
>
<el-icon
class="right_wrap_icon"
title="历史记录"
:title="t('aiChat.history')"
>
<ChatDotRound />
</el-icon>
@@ -74,7 +74,7 @@
<el-icon
:size="18"
class="right_wrap_icon"
title="设置"
:title="t('aiChat.settings')"
@click="handleSetting"
>
<Setting />
@@ -105,7 +105,7 @@
v-model="item.editingContent"
type="textarea"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="编辑消息内容"
:placeholder="t('aiChat.editMessagePlaceholder')"
class="edit_textarea"
/>
<div class="edit_actions">
@@ -115,13 +115,13 @@
:loading="loading"
@click="handleConfirmEdit(item.id, item.editingContent)"
>
确认
{{ t('aiChat.confirmEdit') }}
</el-button>
<el-button
size="small"
@click="handleCancelEdit(item.id)"
>
取消
{{ t('aiChat.cancelEdit') }}
</el-button>
</div>
</div>
@@ -133,8 +133,8 @@
<template #title>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="color: #887dfd;">
{{ (isReasoning && item.isStreaming) ? '思考中' : '已深度思考' }}
{{ item.reasoningTime ? `(用时:${(item.reasoningTime / 1000).toFixed(1)}s)` : '' }}
{{ (isReasoning && item.isStreaming) ? t('aiChat.thinking') : t('aiChat.thoughtDeeply') }}
{{ item.reasoningTime ? t('aiChat.reasoningTime', { seconds: (item.reasoningTime / 1000).toFixed(1) }) : '' }}
</span>
<el-icon class="header-icon" style="color: #887dfd;margin-left: 10px;">
<CircleCheck v-if="!isReasoning" style="color: #887dfd;" />
@@ -183,9 +183,9 @@
</div>
<el-divider v-show="chatList.length > 1" content-position="center" class="clear_history_divider">
<svg-icon name="icon-saoba" style="width: 16px; height: 16px;" />
<el-popconfirm title="确定清空?" @confirm="clearChat">
<el-popconfirm :title="t('aiChat.clearConfirm')" @confirm="clearChat">
<template #reference>
<span class="clear_history">清空历史记录</span>
<span class="clear_history">{{ t('aiChat.clearHistory') }}</span>
</template>
</el-popconfirm>
</el-divider>
@@ -207,6 +207,7 @@ import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import { ref, computed, h, nextTick, watch, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bubble, Sender } from 'ant-design-x-vue'
import { Avatar, Refresh, CopyDocument, Delete, EditPen, CircleCheck, Loading, Setting, ArrowDown, ChatDotRound, Plus } from '@element-plus/icons-vue'
import { useAIChat } from '@/composables/useAIChat'
@@ -235,6 +236,7 @@ md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
const { t } = useI18n()
const { proxy: { $message, $store } } = getCurrentInstance()
const props = defineProps({
@@ -349,7 +351,7 @@ const renderMarkdown = (content) => {
const copyBtn = document.createElement('div')
copyBtn.classList.add('code_btn')
copyBtn.classList.add('code_copy_btn')
copyBtn.innerHTML = '复制'
copyBtn.innerHTML = t('aiChat.copy')
copyBtn.addEventListener('click', (e) => {
e.stopPropagation()
const codeElement = pre.querySelector('code')
@@ -362,7 +364,7 @@ const renderMarkdown = (content) => {
const execBtn = document.createElement('div')
execBtn.classList.add('code_btn')
execBtn.classList.add('code_exec_btn')
execBtn.innerHTML = '执行'
execBtn.innerHTML = t('terminal.execute')
execBtn.addEventListener('click', (e) => {
e.stopPropagation()
const codeElement = pre.querySelector('code')
@@ -426,7 +428,7 @@ const scrollToBottom = () => {
const handleStartEdit = (messageId) => {
if (loading.value) {
$message.warning('请等待当前对话响应完成')
$message.warning(t('aiChat.waitForResponse'))
return
}
startEditMessage(messageId)
@@ -438,7 +440,7 @@ const handleCancelEdit = (messageId) => {
const handleConfirmEdit = async (messageId, newContent) => {
if (!newContent || !newContent.trim()) {
$message.warning('内容不能为空')
$message.warning(t('aiChat.emptyContent'))
return
}
@@ -461,15 +463,15 @@ const submit = async function (questionStr) {
const copyContent = async (content) => {
try {
await navigator.clipboard.writeText(content)
$message.success('复制成功')
$message.success(t('common.copySuccess'))
} catch (err) {
$message.error('复制失败')
$message.error(t('common.copyFailed'))
}
}
const handleSetting = () => {
if (!isPlusActive.value) {
$message.warning('请先激活Plus')
$message.warning(t('aiChat.activatePlusFirst'))
return
}
aiApiConfigVisible.value = true
@@ -477,7 +479,7 @@ const handleSetting = () => {
const isLoadingTips = () => {
if (loading.value) {
$message.warning('请等待当前对话响应完成')
$message.warning(t('aiChat.waitForResponse'))
return true
}
return false
+22 -20
View File
@@ -34,7 +34,7 @@
<div class="right_wrap">
<el-icon
class="right_wrap_icon"
title="新建对话"
:title="t('aiChat.newChat')"
@click="handleNewChat"
>
<Plus />
@@ -46,7 +46,7 @@
>
<el-icon
class="right_wrap_icon"
title="历史记录"
:title="t('aiChat.history')"
>
<ChatDotRound />
</el-icon>
@@ -74,7 +74,7 @@
<el-icon
:size="18"
class="right_wrap_icon"
title="设置"
:title="t('aiChat.settings')"
@click="handleSetting"
>
<Setting />
@@ -111,8 +111,8 @@
<template #title>
<div class="reasoning_title">
<span class="reasoning_text">
{{ (isReasoning && item.isStreaming) ? '思考中' : '已深度思考' }}
{{ item.reasoningTime ? `(用时:${(item.reasoningTime / 1000).toFixed(1)}s)` : '' }}
{{ (isReasoning && item.isStreaming) ? t('aiChat.thinking') : t('aiChat.thoughtDeeply') }}
{{ item.reasoningTime ? t('aiChat.reasoningTime', { seconds: (item.reasoningTime / 1000).toFixed(1) }) : '' }}
</span>
<el-icon class="reasoning_icon">
<CircleCheck v-if="!item.isStreaming" />
@@ -141,7 +141,7 @@
v-model="item.editingContent"
type="textarea"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="编辑消息内容"
:placeholder="t('aiChat.editMessagePlaceholder')"
class="edit_textarea"
/>
<div class="edit_actions">
@@ -151,13 +151,13 @@
:loading="loading"
@click="handleConfirmEdit(item.id, item.editingContent)"
>
确认
{{ t('aiChat.confirmEdit') }}
</el-button>
<el-button
size="small"
@click="handleCancelEdit(item.id)"
>
取消
{{ t('aiChat.cancelEdit') }}
</el-button>
</div>
</div>
@@ -190,21 +190,21 @@
size="small"
:icon="Refresh"
circle
title="重新生成"
:title="t('aiChat.regenerate')"
@click="regenerateMessage(item.id, activeModel)"
/>
<el-button
size="small"
:icon="CopyDocument"
circle
title="复制"
:title="t('aiChat.copy')"
@click="copyContent(item.content)"
/>
<el-button
size="small"
:icon="Delete"
circle
title="删除"
:title="t('aiChat.delete')"
@click="deleteMessage(item.id)"
/>
<el-button
@@ -212,7 +212,7 @@
size="small"
:icon="EditPen"
circle
title="编辑"
:title="t('aiChat.edit')"
@click="handleStartEdit(item.id)"
/>
</div>
@@ -221,9 +221,9 @@
</div>
<el-divider v-show="chatList.length > 1" content-position="center" class="clear_history_divider">
<svg-icon name="icon-saoba" style="width: 16px; height: 16px;" />
<el-popconfirm title="确定清空?" @confirm="clearChat">
<el-popconfirm :title="t('aiChat.clearConfirm')" @confirm="clearChat">
<template #reference>
<span class="clear_history">清空历史记录</span>
<span class="clear_history">{{ t('aiChat.clearHistory') }}</span>
</template>
</el-popconfirm>
</el-divider>
@@ -233,7 +233,7 @@
<el-icon
class="scroll_btn"
:class="{ 'is_disabled': isAtTop }"
title="滚动到顶部"
:title="t('aiChat.scrollToTop')"
@click="scrollToTop"
>
<Top />
@@ -241,7 +241,7 @@
<el-icon
class="scroll_btn"
:class="{ 'is_disabled': isAtBottom }"
title="滚动到底部"
:title="t('aiChat.scrollToBottom')"
@click="scrollToBottomManual"
>
<Bottom />
@@ -262,6 +262,7 @@
<script setup>
import { ref, computed, nextTick, watch, getCurrentInstance, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import MarkdownRender, { setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'highlight.js/styles/github-dark.css'
@@ -274,6 +275,7 @@ import CustomCodeBlock from './custom-code-block.vue'
import clipboard from '@/utils/clipboard'
const { proxy: { $message, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
visible: {
@@ -414,7 +416,7 @@ const scrollToBottomManual = () => {
const handleStartEdit = (messageId) => {
if (loading.value) {
$message.warning('请等待当前对话响应完成')
$message.warning(t('aiChat.waitForResponse'))
return
}
startEditMessage(messageId)
@@ -426,7 +428,7 @@ const handleCancelEdit = (messageId) => {
const handleConfirmEdit = async (messageId, newContent) => {
if (!newContent || !newContent.trim()) {
$message.warning('内容不能为空')
$message.warning(t('aiChat.emptyContent'))
return
}
await confirmEditMessage(messageId, newContent.trim(), activeModel.value)
@@ -451,7 +453,7 @@ const copyContent = (content) => {
const handleSetting = () => {
if (!isPlusActive.value) {
$message.warning('请先激活Plus')
$message.warning(t('aiChat.activatePlusFirst'))
return
}
aiApiConfigVisible.value = true
@@ -459,7 +461,7 @@ const handleSetting = () => {
const isLoadingTips = () => {
if (loading.value) {
$message.warning('请等待当前对话响应完成')
$message.warning(t('aiChat.waitForResponse'))
return true
}
return false
+21 -17
View File
@@ -19,7 +19,7 @@
<codemirror
ref="cmRef"
v-model="code"
placeholder="Code goes here..."
:placeholder="t('componentCodeEdit.placeholder')"
:style="{ height: '80vh', minHeight: '500px' }"
:disabled="disabled"
:autofocus="true"
@@ -28,13 +28,13 @@
:extensions="extensions"
@ready="handleReady"
@change="handleChange"
@focus="status = '编辑中'"
@blur="status = '未聚焦'"
@focus="status = t('componentCodeEdit.statusEditing')"
@blur="status = t('componentCodeEdit.statusBlurred')"
/>
<template #footer>
<footer>
<div v-if="!disabled" class="select_wrap">
<el-select v-model="curLang" placeholder="Select language">
<el-select v-model="curLang" :placeholder="t('componentCodeEdit.selectLanguage')">
<el-option
v-for="item in languageKey"
:key="item"
@@ -50,9 +50,9 @@
:loading="loading"
@click="handleSave"
>
保存
{{ t('common.save') }}
</el-button>
<el-button type="info" @click="handleClose">关闭</el-button>
<el-button type="info" @click="handleClose">{{ t('common.close') }}</el-button>
</div>
</footer>
</template>
@@ -62,6 +62,7 @@
<script>
import { Codemirror } from 'vue-codemirror'
import { oneDark } from '@codemirror/theme-one-dark'
import i18n from '@/i18n'
import languages from './languages'
import { sortString, getSuffix } from '@/utils'
@@ -95,19 +96,22 @@ export default {
default: false
}
},
emits: ['update:show', 'save', 'closed',],
emits: ['update:show', 'save', 'closed'],
data() {
return {
languageKey,
curLang: null,
status: '准备中',
status: this.t('componentCodeEdit.statusPreparing'),
loading: false,
isTips: false,
code: 'loading...',
code: this.t('common.loading'),
editorView: null
}
},
computed: {
t() {
return i18n.global.t
},
extensions() {
let res = []
if (this.curLang) res.push(languages[this.curLang]())
@@ -178,7 +182,7 @@ export default {
},
methods: {
handleReady(payload) {
this.status = '准备中'
this.status = this.t('componentCodeEdit.statusPreparing')
// 保存 view 实例以便后续使用
if (payload && payload.view) {
this.editorView = payload.view
@@ -225,14 +229,14 @@ export default {
})
}
} catch (error) {
console.warn('滚动到底部失败:', error)
console.warn('Failed to scroll to bottom:', error)
}
},
handleSave() {
if (this.isTips) {
this.$messageBox.confirm('文件已变更, 确认保存?', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
this.$messageBox.confirm(this.t('componentCodeEdit.confirmSaveChanged'), this.t('componentCodeEdit.warningTitle'), {
confirmButtonText: this.t('common.confirm'),
cancelButtonText: this.t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -249,9 +253,9 @@ export default {
},
handleClose() {
if (this.isTips && !this.disabled) {
this.$messageBox.confirm('文件已变更, 确认丢弃?', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
this.$messageBox.confirm(this.t('componentCodeEdit.confirmDiscardChanged'), this.t('componentCodeEdit.warningTitle'), {
confirmButtonText: this.t('common.confirm'),
cancelButtonText: this.t('common.cancel'),
type: 'warning'
})
.then(async () => {
+4 -1
View File
@@ -3,7 +3,7 @@
<div class="plus_limit_content">
<el-icon class="lock-icon"><Lock /></el-icon>
<div class="plus_label">PLUS</div>
<p>此功能仅限PLUS版使用, <span class="to_active" @click="gotoPlusPage">去激活</span></p>
<p>{{ t('common.plusOnlyFeature') }}, <span class="to_active" @click="gotoPlusPage">{{ t('common.goActivate') }}</span></p>
</div>
</div>
<slot />
@@ -11,11 +11,13 @@
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Lock } from '@element-plus/icons-vue'
const { proxy: { $store } } = getCurrentInstance()
const router = useRouter()
const { t } = useI18n()
const isPlusActive = computed(() => $store.isPlusActive)
@@ -66,3 +68,4 @@ const gotoPlusPage = () => {
}
}
</style>
+4 -2
View File
@@ -5,14 +5,14 @@
>
<template #content>
<div class="plus_support_tip">
此功能需要激活Plus后使用,
{{ t('common.plusActivationRequired') }}
<el-button
size="small"
type="primary"
link
@click="gotoPlusPage"
>
去激活
{{ t('common.goActivate') }}
</el-button>
</div>
</template>
@@ -22,10 +22,12 @@
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { proxy: { $store } } = getCurrentInstance()
const router = useRouter()
const { t } = useI18n()
const isPlusActive = computed(() => $store.isPlusActive)
@@ -16,7 +16,7 @@
size="small"
disable-transitions
>
已连接
{{ t('fileTransfer.sftpPanel.connected') }}
</el-tag>
<el-tag
v-else-if="connectionStatus === 'connecting'"
@@ -24,7 +24,7 @@
size="small"
disable-transitions
>
连接中
{{ t('fileTransfer.sftpPanel.connecting') }}
</el-tag>
<el-tag
v-else-if="connectionStatus === 'failed'"
@@ -32,7 +32,7 @@
size="small"
disable-transitions
>
连接断开
{{ t('fileTransfer.sftpPanel.failed') }}
</el-tag>
<el-tag
v-else
@@ -40,7 +40,7 @@
size="small"
disable-transitions
>
未连接
{{ t('fileTransfer.sftpPanel.disconnected') }}
</el-tag>
</div>
</div>
@@ -63,7 +63,7 @@
<template v-else>
<div class="empty_state">
<el-icon class="empty_icon"><Monitor /></el-icon>
<p>请选择一台服务器</p>
<p>{{ t('fileTransfer.sftpPanel.selectServer') }}</p>
</div>
</template>
</div>
@@ -72,12 +72,14 @@
<script setup>
import { ref, watch, computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { Monitor } from '@element-plus/icons-vue'
import ServerSelector from '@/components/server-selector.vue'
import SftpV2 from '@/views/terminal/components/sftp-v2.vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
panelSide: {
@@ -1,7 +1,7 @@
<template>
<div class="transfer-preview">
<div class="preview-header">
<h3>传输预览</h3>
<h3>{{ t('componentTransferPreview.title') }}</h3>
<el-tag :type="getMethodType(preview.method)" size="large">
{{ preview.method.toUpperCase() }}
</el-tag>
@@ -11,7 +11,7 @@
<div class="server-card source">
<div class="server-header">
<el-icon><Upload /></el-icon>
<span>源服务器</span>
<span>{{ t('componentTransferPreview.sourceServer') }}</span>
</div>
<div class="server-details">
<h4>{{ preview.sourceHost.name }}</h4>
@@ -26,7 +26,7 @@
<div class="server-card target">
<div class="server-header">
<el-icon><Download /></el-icon>
<span>目标服务器</span>
<span>{{ t('componentTransferPreview.targetServer') }}</span>
</div>
<div class="server-details">
<h4>{{ preview.targetHost.name }}</h4>
@@ -37,10 +37,10 @@
<div class="transfer-summary">
<div class="summary-stats">
<el-statistic title="文件数量" :value="preview.fileCount" suffix="个" />
<el-statistic title="文件夹数量" :value="preview.folderCount" suffix="个" />
<el-statistic title="总大小" :value="formatSize(preview.totalSize)" />
<el-statistic title="预计时间" :value="preview.estimatedTime" />
<el-statistic :title="t('componentTransferPreview.fileCount')" :value="preview.fileCount" :suffix="countSuffix" />
<el-statistic :title="t('componentTransferPreview.folderCount')" :value="preview.folderCount" :suffix="countSuffix" />
<el-statistic :title="t('componentTransferPreview.totalSize')" :value="formatSize(preview.totalSize)" />
<el-statistic :title="t('componentTransferPreview.estimatedTime')" :value="preview.estimatedTime" />
</div>
</div>
@@ -48,7 +48,7 @@
<div class="path-section">
<h4>
<el-icon><Folder /></el-icon>
源文件/文件夹
{{ t('componentTransferPreview.sourceFiles') }}
</h4>
<div class="path-list">
<div
@@ -70,7 +70,7 @@
<div class="path-section">
<h4>
<el-icon><Folder /></el-icon>
目标路径
{{ t('componentTransferPreview.targetPath') }}
</h4>
<div class="target-path">
<el-icon><Folder /></el-icon>
@@ -80,7 +80,7 @@
</div>
<div class="method-info">
<h4>传输方法说明</h4>
<h4>{{ t('componentTransferPreview.methodDescription') }}</h4>
<div class="method-description">
<template v-if="preview.method === 'scp'">
<el-alert
@@ -91,10 +91,10 @@
<template #title>
SCP (Secure Copy Protocol)
</template>
<p>• 基于SSH协议的安全文件传输</p>
<p>• 适合传输小到中等大小的文件</p>
<p>• 简单快速,但不支持增量传输</p>
<p>• 传输过程中会保持文件权限和时间戳</p>
<p>{{ t('componentTransferPreview.scp1') }}</p>
<p>{{ t('componentTransferPreview.scp2') }}</p>
<p>{{ t('componentTransferPreview.scp3') }}</p>
<p>{{ t('componentTransferPreview.scp4') }}</p>
</el-alert>
</template>
<template v-else-if="preview.method === 'rsync'">
@@ -106,10 +106,10 @@
<template #title>
Rsync (Remote Sync)
</template>
<p>• 高效的增量同步工具</p>
<p>• 支持断点续传和增量更新</p>
<p>• 适合传输大文件或进行目录同步</p>
<p>• 内置压缩和进度显示功能</p>
<p>{{ t('componentTransferPreview.rsync1') }}</p>
<p>{{ t('componentTransferPreview.rsync2') }}</p>
<p>{{ t('componentTransferPreview.rsync3') }}</p>
<p>{{ t('componentTransferPreview.rsync4') }}</p>
</el-alert>
</template>
</div>
@@ -121,17 +121,19 @@
:closable="false"
show-icon
>
<template #title>注意事项</template>
<p>• 传输过程中请确保网络连接稳定</p>
<p>• 目标路径如果存在同名文件将会被覆盖</p>
<p>• 传输大文件时建议使用Rsync方法</p>
<p>• 传输过程可在任务管理器中查看进度</p>
<template #title>{{ t('componentTransferPreview.notes') }}</template>
<p>{{ t('componentTransferPreview.note1') }}</p>
<p>{{ t('componentTransferPreview.note2') }}</p>
<p>{{ t('componentTransferPreview.note3') }}</p>
<p>{{ t('componentTransferPreview.note4') }}</p>
</el-alert>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Upload, Download, ArrowRight, Folder, Document } from '@element-plus/icons-vue'
const props = defineProps({
@@ -143,6 +145,10 @@ const props = defineProps({
const emit = defineEmits(['confirm', 'cancel',])
const { t, locale } = useI18n()
const countSuffix = computed(() => (locale.value === 'en' ? '' : '个'))
const getMethodType = (method) => {
return method === 'rsync' ? 'success' : 'primary'
}
@@ -2,18 +2,18 @@
<div class="transfer-task-manager">
<div class="task-filter">
<el-radio-group v-model="filterStatus" size="">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="running">传输中</el-radio-button>
<el-radio-button label="completed">传输完成</el-radio-button>
<el-radio-button label="failed">传输失败</el-radio-button>
<el-radio-button label="cancelled">已取消</el-radio-button>
<el-radio-button label="all">{{ t('componentTransferTaskManager.filters.all') }}</el-radio-button>
<el-radio-button label="running">{{ t('componentTransferTaskManager.filters.running') }}</el-radio-button>
<el-radio-button label="completed">{{ t('componentTransferTaskManager.filters.completed') }}</el-radio-button>
<el-radio-button label="failed">{{ t('componentTransferTaskManager.filters.failed') }}</el-radio-button>
<el-radio-button label="cancelled">{{ t('componentTransferTaskManager.filters.cancelled') }}</el-radio-button>
</el-radio-group>
</div>
<div class="task-list">
<div v-if="filteredTasks.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>{{ filterStatus === 'all' ? '暂无传输任务' : `暂无${getStatusText(filterStatus)}的任务` }}</p>
<p>{{ filterStatus === 'all' ? t('componentTransferTaskManager.empty') : t('componentTransferTaskManager.emptyWithStatus', { status: getStatusText(filterStatus) }) }}</p>
</div>
<div
@@ -43,7 +43,7 @@
type="danger"
@click="$emit('cancel-task', task.taskId)"
>
取消
{{ t('common.cancel') }}
</el-button>
<el-button
v-if="task.status === 'failed'"
@@ -51,7 +51,7 @@
type="primary"
@click="$emit('retry-task', task.taskId)"
>
重试
{{ t('fileTransfer.sftp.retry') }}
</el-button>
<el-button
v-if="task.status !== 'running'"
@@ -60,13 +60,13 @@
plain
@click="deleteTask(task.taskId)"
>
删除
{{ t('fileTransfer.sftp.delete') }}
</el-button>
<el-button
size="small"
@click="toggleTaskDetails(task.taskId)"
>
{{ expandedTasks.has(task.taskId) ? '收起' : '详情' }}
{{ expandedTasks.has(task.taskId) ? t('componentTransferTaskManager.collapse') : t('componentTransferTaskManager.details') }}
</el-button>
</div>
</div>
@@ -76,12 +76,12 @@
<div class="progress-info">
<span v-if="task.isVerifying">
<el-icon class="verifying-icon"><Loading /></el-icon>
传输完成,校验中...
{{ t('componentTransferTaskManager.verifying') }}
</span>
<template v-else>
<span>总体进度: {{ task.progress || 0 }}%</span>
<span v-if="task.fileProgress">文件进度: {{ task.completedFiles || 0 }}/{{ task.totalFiles || 1 }}</span>
<span v-if="task.speed">速度: {{ formatSpeed(task.speed) }}</span>
<span>{{ t('componentTransferTaskManager.overallProgress') }}: {{ task.progress || 0 }}%</span>
<span v-if="task.fileProgress">{{ t('componentTransferTaskManager.fileProgress') }}: {{ task.completedFiles || 0 }}/{{ task.totalFiles || 1 }}</span>
<span v-if="task.speed">{{ t('componentTransferTaskManager.speed') }}: {{ formatSpeed(task.speed) }}</span>
</template>
</div>
<el-progress
@@ -115,15 +115,15 @@
<div v-if="expandedTasks.has(task.taskId)" class="task-details">
<div class="details-grid">
<div class="detail-item">
<label>任务ID:</label>
<label>{{ t('componentTransferTaskManager.taskId') }}</label>
<span>{{ task.taskId }}</span>
</div>
<div class="detail-item">
<label>传输方法:</label>
<label>{{ t('componentTransferTaskManager.transferMethod') }}</label>
<span>{{ task.method.toUpperCase() }}</span>
</div>
<div class="detail-item">
<label>源路径:</label>
<label>{{ t('componentTransferTaskManager.sourcePath') }}</label>
<div class="path-list">
<div v-for="(path, index) in task.sourcePaths" :key="index" class="path-item">
<el-icon>
@@ -136,26 +136,26 @@
</div>
</div>
<div class="detail-item">
<label>目标路径:</label>
<label>{{ t('componentTransferTaskManager.targetPath') }}</label>
<span>{{ task.targetPath }}</span>
</div>
<div class="detail-item">
<label>总大小:</label>
<label>{{ t('componentTransferTaskManager.totalSize') }}</label>
<span>{{ formatSize(task.totalSize) }}</span>
</div>
<div class="detail-item">
<label>创建时间:</label>
<label>{{ t('componentTransferTaskManager.createdAt') }}</label>
<span>{{ formatDateTime(task.createTime) }}</span>
</div>
<div v-if="task.updateTime !== task.createTime" class="detail-item">
<label>更新时间:</label>
<label>{{ t('componentTransferTaskManager.updatedAt') }}</label>
<span>{{ formatDateTime(task.updateTime) }}</span>
</div>
</div>
<!-- 传输选项 -->
<div v-if="task.options && Object.keys(task.options).length > 0" class="transfer-options">
<h5>传输选项:</h5>
<h5>{{ t('fileTransfer.transferOptionsTitle') }}:</h5>
<div class="options-list">
<el-tag
v-for="(value, key) in task.options"
@@ -163,7 +163,7 @@
size="small"
:type="value ? 'success' : 'info'"
>
{{ getOptionName(key) }}: {{ value ? '是' : '否' }}
{{ getOptionName(key) }}: {{ value ? t('terminal.yes') : t('terminal.no') }}
</el-tag>
</div>
</div>
@@ -176,6 +176,7 @@
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Document, Folder, Loading } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
@@ -188,6 +189,8 @@ const props = defineProps({
const emit = defineEmits(['cancel-task', 'retry-task', 'delete-task', 'refresh',])
const { t, locale } = useI18n()
const filterStatus = ref('all')
const expandedTasks = ref(new Set())
@@ -226,11 +229,11 @@ const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm(
`确定要删除传输任务 "${ task.sourceHostName } → ${ task.targetHostName }" 吗?`,
'删除任务',
t('componentTransferTaskManager.deleteConfirm', { source: task.sourceHostName, target: task.targetHostName }),
t('componentTransferTaskManager.deleteTask'),
{
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonText: t('fileTransfer.sftp.delete'),
cancelButtonText: t('common.cancel'),
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
@@ -254,19 +257,19 @@ const getStatusType = (status) => {
const getStatusText = (status) => {
const statusMap = {
running: '传输中',
completed: '传输完成',
failed: '传输失败',
cancelled: '已取消'
running: t('componentTransferTaskManager.filters.running'),
completed: t('componentTransferTaskManager.filters.completed'),
failed: t('componentTransferTaskManager.filters.failed'),
cancelled: t('componentTransferTaskManager.filters.cancelled')
}
return statusMap[status] || status
}
const getOptionName = (key) => {
const optionMap = {
delete: '删除多余文件',
partial: '断点续传',
compress: '压缩传输'
delete: t('fileTransfer.deleteExtraFiles'),
partial: t('fileTransfer.resumeTransfer'),
compress: t('fileTransfer.enableCompression')
}
return optionMap[key] || key
}
@@ -274,7 +277,7 @@ const getOptionName = (key) => {
const formatDateTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString('zh-CN')
return date.toLocaleString(locale.value === 'en' ? 'en-US' : 'zh-CN')
}
const formatSize = (bytes) => {
+25 -22
View File
@@ -38,7 +38,7 @@
<el-button size="small" style="margin-left: 8px;" @click="resetTransform">
<el-icon><Refresh /></el-icon>
重置
{{ t('componentImagePreview.reset') }}
</el-button>
<el-button
@@ -48,7 +48,7 @@
@click="downloadImage"
>
<el-icon><Download /></el-icon>
下载
{{ t('fileTransfer.sftp.download') }}
</el-button>
</div>
</div>
@@ -75,13 +75,13 @@
<!-- 加载状态 -->
<div v-if="loading" class="loading_overlay">
<el-icon class="is-loading loading_icon"><Loading /></el-icon>
<p>正在加载图片...</p>
<p>{{ t('componentImagePreview.loading') }}</p>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error_overlay">
<el-icon class="error_icon"><WarningFilled /></el-icon>
<p>图片加载失败</p>
<p>{{ t('componentImagePreview.loadFailed') }}</p>
<p class="error_message">{{ error }}</p>
</div>
</div>
@@ -89,32 +89,33 @@
<!-- 图片信息面板 -->
<div v-if="imageLoaded" class="info_panel">
<div class="info_item">
<label>文件名:</label>
<label>{{ t('componentImagePreview.fileName') }}</label>
<span>{{ fileName }}</span>
</div>
<div class="info_item">
<label>文件大小:</label>
<label>{{ t('componentImagePreview.fileSize') }}</label>
<span>{{ formatFileSize(fileSize) }}</span>
</div>
<div class="info_item">
<label>图片尺寸:</label>
<label>{{ t('componentImagePreview.imageSize') }}</label>
<span>{{ imageWidth }} × {{ imageHeight }}</span>
</div>
<div class="info_item">
<label>文件类型:</label>
<label>{{ t('componentImagePreview.fileType') }}</label>
<span>{{ getFileType(fileName) }}</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="closeDialog">关闭</el-button>
<el-button @click="closeDialog">{{ t('common.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ZoomIn, ZoomOut, RefreshLeft, RefreshRight, Refresh, Download, Loading, WarningFilled } from '@element-plus/icons-vue'
const props = defineProps({
@@ -142,6 +143,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'download',])
const { t } = useI18n()
// 对话框显示状态
const showDialog = computed({
get: () => props.modelValue,
@@ -171,7 +174,7 @@ const dragStartTranslateX = ref(0)
const dragStartTranslateY = ref(0)
// 计算属性
const title = computed(() => `图片预览 - ${ props.fileName }`)
const title = computed(() => t('componentImagePreview.title', { fileName: props.fileName }))
const imageInfo = computed(() => {
if (!imageLoaded.value) return ''
@@ -202,18 +205,18 @@ const isImageFile = (filename) => {
const getFileType = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase()
const typeMap = {
'jpg': 'JPEG图片',
'jpeg': 'JPEG图片',
'png': 'PNG图片',
'gif': 'GIF动图',
'bmp': 'BMP图片',
'webp': 'WebP图片',
'svg': 'SVG矢量图',
'ico': 'ICO图标',
'tiff': 'TIFF图片',
'tif': 'TIFF图片'
'jpg': t('componentImagePreview.types.jpeg'),
'jpeg': t('componentImagePreview.types.jpeg'),
'png': t('componentImagePreview.types.png'),
'gif': t('componentImagePreview.types.gif'),
'bmp': t('componentImagePreview.types.bmp'),
'webp': t('componentImagePreview.types.webp'),
'svg': t('componentImagePreview.types.svg'),
'ico': t('componentImagePreview.types.ico'),
'tiff': t('componentImagePreview.types.tiff'),
'tif': t('componentImagePreview.types.tiff')
}
return typeMap[ext] || '未知格式'
return typeMap[ext] || t('componentImagePreview.types.unknown')
}
// 格式化文件大小
@@ -256,7 +259,7 @@ const onImageError = (e) => {
})
loading.value = false
error.value = '图片加载失败,请检查文件格式或网络连接'
error.value = t('componentImagePreview.errorMessage')
imageLoaded.value = false
}
+7 -5
View File
@@ -11,20 +11,20 @@
>
<template #header>
<div class="title">
输入多行命令发送到终端执行
{{ t('terminal.multilineCommandTitle') }}
</div>
</template>
<el-input
v-model="command"
:autosize="{ minRows: 10, maxRows: 20 }"
type="textarea"
placeholder="Please input command"
:placeholder="t('terminal.multilineCommandPlaceholder')"
/>
<template #footer>
<footer>
<div class="btns">
<el-button type="primary" @click="handleSave">发送到终端</el-button>
<el-button type="info" @click="visible = false">关闭</el-button>
<el-button type="primary" @click="handleSave">{{ t('terminal.sendToTerminal') }}</el-button>
<el-button type="info" @click="visible = false">{{ t('common.close') }}</el-button>
</div>
</footer>
</template>
@@ -32,6 +32,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
show: {
@@ -43,6 +44,7 @@ const props = defineProps({
const emit = defineEmits(['update:show', 'closed', 'input-command',])
const command = ref('')
const { t } = useI18n()
const visible = computed({
get() {
@@ -96,4 +98,4 @@ const handleSave = () => {
}
}
}
</style>
</style>
+13 -11
View File
@@ -26,17 +26,17 @@
<!-- 工具栏 -->
<div class="viewer-toolbar">
<div class="toolbar-item">
<label>自动换行</label>
<label>{{ t('componentLogViewer.wordWrap') }}</label>
<el-switch v-model="wordWrapEnabled" size="small" @change="toggleWordWrap" />
</div>
<div class="toolbar-item">
<label>缩略图</label>
<label>{{ t('componentLogViewer.minimap') }}</label>
<el-switch v-model="minimapEnabled" size="small" @change="toggleMinimap" />
</div>
<div class="toolbar-item">
<label>自动滚动</label>
<label>{{ t('componentLogViewer.autoScroll') }}</label>
<el-switch v-model="autoScrollEnabled" size="small" />
</div>
@@ -46,7 +46,7 @@
:icon="RefreshRight"
@click="manualRefresh"
>
手动刷新
{{ t('componentLogViewer.manualRefresh') }}
</el-button>
</div>
</div>
@@ -54,7 +54,7 @@
<div
v-loading="loading"
class="viewer-wrapper"
element-loading-text="加载中..."
:element-loading-text="t('common.loading')"
>
<div ref="editorContainer" class="monaco-editor" />
</div>
@@ -62,14 +62,14 @@
<!-- 底部信息栏 -->
<div class="viewer-footer">
<div class="footer-info">
<span class="info-item">行数: {{ lineCount }}</span>
<span class="info-item">大小: {{ formatSize(contentSize) }}</span>
<span class="info-item">{{ t('componentLogViewer.lineCount') }}: {{ lineCount }}</span>
<span class="info-item">{{ t('componentLogViewer.size') }}: {{ formatSize(contentSize) }}</span>
<span v-if="lastUpdateTime" class="info-item">
最后更新: {{ lastUpdateTime }}
{{ t('componentLogViewer.lastUpdated') }}: {{ lastUpdateTime }}
</span>
</div>
<el-button size="small" @click="closeDialog">
关闭
{{ t('common.close') }}
</el-button>
</div>
</div>
@@ -79,6 +79,7 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Close, RefreshRight } from '@element-plus/icons-vue'
import * as monaco from 'monaco-editor'
import useStore from '@/store'
@@ -94,7 +95,7 @@ const props = defineProps({
},
title: {
type: String,
default: '日志查看器'
default: ''
},
language: {
type: String,
@@ -104,6 +105,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'refresh',])
const { t } = useI18n()
const store = useStore()
const visible = ref(false)
@@ -231,7 +233,7 @@ const scrollToBottom = () => {
const manualRefresh = () => {
emit('refresh')
ElMessage.success('已请求刷新日志')
ElMessage.success(t('componentLogViewer.refreshRequested'))
}
const startAutoRefresh = (immediate = false) => {
+15 -15
View File
@@ -20,8 +20,9 @@
</template>
<script setup>
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
import { computed, markRaw, getCurrentInstance, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Menu as IconMenu,
Key,
@@ -30,7 +31,6 @@ import {
ArrowRight,
Pointer,
FolderOpened
// FolderOpened
} from '@element-plus/icons-vue'
const props = defineProps({
@@ -41,53 +41,54 @@ const props = defineProps({
})
const { proxy: { $router, $store } } = getCurrentInstance()
const { t } = useI18n()
const emit = defineEmits(['select',])
const route = useRoute()
const list = reactive([
const list = computed(() => ([
{
name: '实例列表',
name: t('menu.serverList'),
icon: markRaw(IconMenu),
index: '/server'
},
{
name: '远程终端',
name: t('menu.terminal'),
icon: markRaw(ScaleToOriginal),
index: '/terminal'
},
{
name: '远程桌面',
name: t('menu.rdp'),
icon: markRaw(ScaleToOriginal),
index: '/rdp'
},
{
name: '文件对传',
name: t('menu.fileTransfer'),
icon: markRaw(FolderOpened),
index: '/file'
},
{
name: '凭据管理',
name: t('menu.credentials'),
icon: markRaw(Key),
index: '/credentials'
},
{
name: '脚本库',
name: t('menu.scripts'),
icon: markRaw(ArrowRight),
index: '/scripts'
},
{
name: '批量指令',
name: t('menu.onekey'),
icon: markRaw(Pointer),
index: '/onekey'
},
{
name: '系统设置',
name: t('menu.settings'),
icon: markRaw(Setting),
index: '/setting'
},
])
]))
const menuCollapse = computed(() => props.mode === 'horizontal' ? false : $store.menuCollapse)
@@ -100,12 +101,11 @@ const defaultActiveMenu = computed(() => {
watchEffect(() => {
const idx = route.path.match(regex)[0]
const targetRoute = list.find(item => item.index === idx)
const targetRoute = list.value.find(item => item.index === idx)
$store.setTitle(targetRoute?.name || '')
})
const handleSelect = (path) => {
// console.log(path)
$router.push(path)
emit('select', path)
}
@@ -119,4 +119,4 @@ const handleSelect = (path) => {
}
}
}
</style>
</style>
+38 -42
View File
@@ -1,10 +1,9 @@
<template>
<div class="comparison-container">
<!-- 基础版卡片 -->
<el-card class="comparison-card basic-card">
<template #header>
<div class="card-header">
<span class="title">基础功能(免费)</span>
<span class="title">{{ t('plusTable.basicTitle') }}</span>
<el-tag size="small">Basic</el-tag>
</div>
</template>
@@ -18,13 +17,12 @@
</div>
</el-card>
<!-- Plus版卡片 -->
<el-card class="comparison-card plus-card">
<template #header>
<div class="card-header">
<div>
<span class="title">Plus专属功能</span>
<span class="link" style="margin-right: 15px;" @click="() => plusTipsShow = true">Plus说明</span>
<span class="title">{{ t('plusTable.plusTitle') }}</span>
<span class="link" style="margin-right: 15px;" @click="() => plusTipsShow = true">{{ t('plusTable.plusDescription') }}</span>
</div>
<el-tag type="success" size="small">PLUS</el-tag>
</div>
@@ -40,7 +38,7 @@
</el-card>
<el-dialog
v-model="plusTipsShow"
title="Plus说明"
:title="t('plusTable.plusDescription')"
top="20vh"
width="30%"
:append-to-body="false"
@@ -48,28 +46,26 @@
>
<div class="about_content">
<p style="line-height: 2;letter-spacing: 1px;">
<!-- <strong style="color: #F56C6C;font-weight: 600;">PLUS说明:</strong><br> -->
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<strong>EasyNode</strong>最初是一个简单的Web终端工具,随着用户群的不断扩大,功能需求也日益增长,为了实现大家的功能需求,我投入了大量的业余时间进行开发和维护。
一直在为爱发电,渐渐的也没了开发的动力。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<strong>EasyNode</strong>{{ t('plusTable.intro1') }}
{{ t('plusTable.intro2') }}
<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;为了项目的可持续发展,<strong>后续</strong>版本开始推出<strong>PLUS</strong>版本。后续特性功能开发也会优先在<strong>PLUS</strong>版本中实现,但即使不升级到<strong>PLUS</strong>,也不会影响到<strong>EasyNode</strong>的基础功能使用【注意:
暂不支持纯内网用户激活PLUS功能】。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{{ t('plusTable.intro3') }}
<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
注意:激活码只为激活面板<strong>PLUS</strong>功能使用,不作其他用途。您获取的激活码仅限本人使用,任何分享他人的行为都有可能导致激活码&服务器IP被永久封禁。
{{ t('plusTable.intro4') }}
<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<span style="text-decoration: underline;">
为了感谢前期赞赏过的用户, 在<strong>PLUS</strong>功能正式发布前,所有进行过赞赏的用户,无论金额大小,均可联系作者TG: <a
{{ t('plusTable.intro5Prefix') }}<a
class="link"
href="https://t.me/chaoszhu"
target="_blank"
>@chaoszhu</a> 凭打赏记录免费获取永久<strong>PLUS</strong>授权码。
>@chaoszhu</a>{{ t('plusTable.intro5Suffix') }}
</span>
</p>
<div class="about_footer">
<el-button type="info" @click="plusTipsShow = false">关闭</el-button>
<el-button type="primary" @click="handlePlusSupport">获取Plus Key</el-button>
<el-button type="info" @click="plusTipsShow = false">{{ t('plusTable.close') }}</el-button>
<el-button type="primary" @click="handlePlusSupport">{{ t('plusTable.getPlusKey') }}</el-button>
</div>
</div>
</el-dialog>
@@ -77,38 +73,38 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check } from '@element-plus/icons-vue'
import { handlePlusSupport } from '@/utils'
const plusTipsShow = ref(false)
const { t } = useI18n()
// 基础版功能列表
const basicFeatures = [
'服务器管理',
'服务器导入导出',
'服务器分组',
'凭据管理',
'脚本库',
'批量连接',
'批量指令',
'通知方式(有限制)',
]
const basicFeatures = computed(() => [
t('plusTable.basicFeatures.serverManagement'),
t('plusTable.basicFeatures.serverImportExport'),
t('plusTable.basicFeatures.serverGrouping'),
t('plusTable.basicFeatures.credentials'),
t('plusTable.basicFeatures.scriptLibrary'),
t('plusTable.basicFeatures.batchConnect'),
t('plusTable.basicFeatures.batchCommand'),
t('plusTable.basicFeatures.notificationsLimited'),
])
// Plus版专属功能列表
const plusFeatures = [
'AI Chat对话组件',
'服务器代理&跳板机功能',
'RDP远程win桌面连接',
'文件对传',
'终端单窗口模式',
'批量修改实例配置',
'脚本库批量导出导入',
'脚本库分组与终端功能栏支持',
'终端功能栏docker容器管理',
'凭据管理支持解密带密码保护的密钥',
'终端会话挂起',
]
const plusFeatures = computed(() => [
t('plusTable.plusFeatures.aiChat'),
t('plusTable.plusFeatures.proxyAndJumpHost'),
t('plusTable.plusFeatures.rdp'),
t('plusTable.plusFeatures.fileTransfer'),
t('plusTable.plusFeatures.singleWindow'),
t('plusTable.plusFeatures.batchModify'),
t('plusTable.plusFeatures.scriptImportExport'),
t('plusTable.plusFeatures.scriptGroupSupport'),
t('plusTable.plusFeatures.dockerManager'),
t('plusTable.plusFeatures.encryptedKey'),
t('plusTable.plusFeatures.sessionSuspend'),
])
</script>
<style scoped lang="scss">
+4 -2
View File
@@ -3,7 +3,7 @@
<el-select
v-model="selectedServer"
:disabled="!isPlusActive"
placeholder="选择服务器"
:placeholder="t('common.selectServer')"
filterable
clearable
class="server-select"
@@ -26,6 +26,7 @@
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
modelValue: {
@@ -37,6 +38,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'change',])
const { proxy: { $store } } = getCurrentInstance()
const { t } = useI18n()
const serverList = computed(() => $store.hostList?.filter(item => item.connectType !== 'rdp' && item.isConfig))
const isPlusActive = computed(() => $store.isPlusActive)
@@ -74,4 +76,4 @@ const handleServerChange = (serverId) => {
}
}
}
</style>
</style>
+48 -43
View File
@@ -26,16 +26,16 @@
<!-- 工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-item">
<label>主题</label>
<label>{{ t('componentTextEditor.theme') }}</label>
<el-select v-model="selectedTheme" size="small" @change="changeTheme">
<el-option label="High Contrast Dark" value="hc-black" />
<el-option label="Dark" value="vs-dark" />
<el-option label="Light" value="vs" />
<el-option :label="t('componentTextEditor.themeOptions.highContrastDark')" value="hc-black" />
<el-option :label="t('componentTextEditor.themeOptions.dark')" value="vs-dark" />
<el-option :label="t('componentTextEditor.themeOptions.light')" value="vs" />
</el-select>
</div>
<div class="toolbar-item">
<label>类型</label>
<label>{{ t('componentTextEditor.type') }}</label>
<el-select v-model="selectedLanguage" size="small" @change="changeLanguage">
<el-option label="plaintext" value="plaintext" />
<el-option label="json" value="json" />
@@ -61,18 +61,18 @@
</div>
<div class="toolbar-item">
<label>行尾符</label>
<label>{{ t('componentTextEditor.eol') }}</label>
<el-select v-model="selectedEOL" size="small" @change="changeEOL">
<el-option label="LF (Linux)" value="LF" />
<el-option label="CRLF (Windows)" value="CRLF" />
<el-option label="CR (Mac)" value="CR" />
<el-option :label="t('componentTextEditor.eolOptions.lf')" value="LF" />
<el-option :label="t('componentTextEditor.eolOptions.crlf')" value="CRLF" />
<el-option :label="t('componentTextEditor.eolOptions.cr')" value="CR" />
</el-select>
</div>
<div class="toolbar-item">
<label>编码</label>
<label>{{ t('componentTextEditor.encoding') }}</label>
<el-select v-model="selectedEncoding" size="small" @change="changeEncoding">
<el-option label="自动检测" value="auto" />
<el-option :label="t('componentTextEditor.encodingOptions.auto')" value="auto" />
<el-option label="UTF-8" value="utf8" />
<el-option label="GB18030" value="gb18030" />
<!-- <el-option label="GBK" value="gbk" />
@@ -81,31 +81,31 @@
</div>
<div class="toolbar-item">
<label>自动换行</label>
<label>{{ t('componentTextEditor.wordWrap') }}</label>
<el-switch v-model="wordWrapEnabled" size="small" @change="toggleWordWrap" />
</div>
<div class="toolbar-item">
<label>缩略图</label>
<label>{{ t('componentTextEditor.minimap') }}</label>
<el-switch v-model="minimapEnabled" size="small" @change="toggleMinimap" />
</div>
<div v-if="minimapEnabled" class="toolbar-item">
<label>缩略图大小</label>
<label>{{ t('componentTextEditor.minimapSize') }}</label>
<el-select
v-model="minimapSize"
size="small"
class="size-select"
@change="changeMinimapSize"
>
<el-option label="小" value="small" />
<el-option label="中" value="medium" />
<el-option label="大" value="large" />
<el-option :label="t('componentTextEditor.sizeOptions.small')" value="small" />
<el-option :label="t('componentTextEditor.sizeOptions.medium')" value="medium" />
<el-option :label="t('componentTextEditor.sizeOptions.large')" value="large" />
</el-select>
</div>
<div class="toolbar-item">
<label>字号</label>
<label>{{ t('componentTextEditor.fontSize') }}</label>
<el-select
v-model="fontSize"
size="small"
@@ -126,7 +126,7 @@
<div
v-loading="loading"
class="editor-wrapper"
element-loading-text="加载中..."
:element-loading-text="t('common.loading')"
>
<div ref="editorContainer" class="monaco-editor" />
</div>
@@ -134,10 +134,10 @@
<!-- 底部按钮 -->
<div class="editor-footer">
<el-button size="small" @click="closeDialog">
关闭
{{ t('common.close') }}
</el-button>
<el-button size="small" @click="resetFile">
重置
{{ t('componentTextEditor.reset') }}
</el-button>
<el-button
:disabled="!hasChanges"
@@ -145,7 +145,7 @@
size="small"
@click="saveFile"
>
保存
{{ t('common.save') }}
</el-button>
</div>
</div>
@@ -155,6 +155,7 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Close } from '@element-plus/icons-vue'
import * as monaco from 'monaco-editor'
import useStore from '@/store'
@@ -184,6 +185,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'saved',])
const { t } = useI18n()
const store = useStore()
const visible = ref(false)
@@ -226,7 +228,10 @@ let shouldCloseAfterSave = false
let actualEncoding = 'utf8' // 实际使用的编码
const title = computed(() => {
return `编辑文件 - ${ props.filePath } ${ hasChanges.value ? ' [已变更]' : '' }`
return t('componentTextEditor.title', {
filePath: props.filePath,
changed: hasChanges.value ? t('componentTextEditor.changed') : ''
}).trim()
})
// 缩略图配置映射
@@ -397,11 +402,11 @@ const saveFile = async () => {
const handleClose = (done) => {
if (hasChanges.value) {
ElMessageBox.confirm(
'文件已修改,是否保存后关闭?',
'确认关闭',
t('componentTextEditor.saveBeforeClose'),
t('componentTextEditor.closeConfirmTitle'),
{
confirmButtonText: '保存并关闭',
cancelButtonText: '不保存',
confirmButtonText: t('componentTextEditor.saveAndClose'),
cancelButtonText: t('componentTextEditor.doNotSave'),
type: 'warning',
showCancelButton: true,
cancelButtonClass: 'el-button--info',
@@ -425,11 +430,11 @@ const handleClose = (done) => {
const closeDialog = () => {
if (hasChanges.value) {
ElMessageBox.confirm(
'文件已修改,是否保存后关闭?',
'确认关闭',
t('componentTextEditor.saveBeforeClose'),
t('componentTextEditor.closeConfirmTitle'),
{
confirmButtonText: '保存并关闭',
cancelButtonText: '不保存',
confirmButtonText: t('componentTextEditor.saveAndClose'),
cancelButtonText: t('componentTextEditor.doNotSave'),
type: 'warning',
showCancelButton: true,
cancelButtonClass: 'el-button--info',
@@ -472,11 +477,11 @@ const changeEncoding = (newEncoding) => {
// 如果有未保存的更改或编辑器已加载内容,提示用户
if (hasChanges.value || (editor && editor.getValue())) {
ElMessageBox.confirm(
'切换编码将重新加载文件,未保存的修改将丢失。是否继续?',
'切换编码',
t('componentTextEditor.switchEncodingWarning'),
t('componentTextEditor.switchEncodingTitle'),
{
confirmButtonText: '继续',
cancelButtonText: '取消',
confirmButtonText: t('componentTextEditor.continueAction'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
).then(() => {
@@ -559,17 +564,17 @@ const changeFontSize = (size) => {
const resetFile = () => {
if (editor && originalContent.value !== undefined) {
ElMessageBox.confirm(
'确认重置文件内容到初始状态?',
'重置确认',
t('componentTextEditor.resetConfirmMessage'),
t('componentTextEditor.resetConfirmTitle'),
{
confirmButtonText: '确认重置',
cancelButtonText: '取消',
confirmButtonText: t('componentTextEditor.confirmReset'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
).then(() => {
editor.setValue(originalContent.value)
hasChanges.value = false
ElMessage.success('文件内容已重置')
ElMessage.success(t('componentTextEditor.resetSuccess'))
}).catch(() => {
// 取消重置
})
@@ -619,7 +624,7 @@ const setupSocketListeners = () => {
originalContent.value = editor.getValue()
hasChanges.value = false
loading.value = false
ElMessage.success('文件保存成功')
ElMessage.success(t('componentTextEditor.saveSuccess'))
emit('saved', { filePath })
// 控制台打印编码信息
@@ -637,14 +642,14 @@ const setupSocketListeners = () => {
props.socket.on('file_read_error', ({ error, filePath }) => {
if (filePath === props.filePath) {
loading.value = false
ElMessage.error(`读取文件失败: ${ error }`)
ElMessage.error(t('componentTextEditor.readFailed', { error }))
}
})
props.socket.on('file_save_error', ({ error, filePath }) => {
if (filePath === props.filePath) {
loading.value = false
ElMessage.error(`保存文件失败: ${ error }`)
ElMessage.error(t('componentTextEditor.saveFailed', { error }))
}
})
}
+64 -23
View File
@@ -31,8 +31,24 @@
link
@click="visible = true"
>
版本更新 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
{{ t('topBar.versionUpdate') }} <span class="new_version">{{ isNew ? `(${t('topBar.newVersionAvailable')})` : '' }}</span>
</el-button>
<el-dropdown trigger="click" @command="handleLanguageChange">
<span class="language_switch">
<el-icon>
<Operation />
</el-icon>
<span>{{ currentLanguageLabel }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">{{ t('topBar.languageNames.zhCN') }}</el-dropdown-item>
<el-dropdown-item command="en">{{ t('topBar.languageNames.en') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click">
<span class="username_wrap">
<el-icon>
@@ -43,7 +59,7 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
退出登录
{{ t('topBar.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -66,18 +82,19 @@
<el-dialog
v-model="visible"
title="版本更新"
:title="t('topBar.versionUpdate')"
top="20vh"
width="30%"
:append-to-body="false"
:close-on-click-modal="false"
>
<div class="about_content">
<!-- <h1>EasyNode</h1> -->
<p>当前版本: {{ currentVersion }} <span v-show="!isNew">(最新)</span> </p>
<p v-if="checkVersionErr" class="conspicuous">Error:版本更新检测失败(版本检测API需要外网环境),请手动访问GitHub查看</p>
<p>
{{ isNew ? t('topBar.currentVersion', { version: currentVersion }) : t('topBar.currentVersionLatest', { version: currentVersion }) }}
</p>
<p v-if="checkVersionErr" class="conspicuous">{{ t('topBar.versionCheckFailed') }}</p>
<p v-if="isNew" class="conspicuous">
新版本可用: {{ latestVersion }} -> <a
{{ t('topBar.latestVersionAvailable', { version: latestVersion }) }} -> <a
class="link"
href="https://github.com/chaos-zhu/easynode?tab=readme-ov-file#%E9%A1%B9%E7%9B%AE%E9%83%A8%E7%BD%B2"
target="_blank"
@@ -86,7 +103,7 @@
<template v-if="features.length > 0 && isNew">
<div class="version_features_title">
<el-icon><Document /></el-icon>
最新版本更新了以下内容(<a href="https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md" target="_blank">更新日志</a>):
{{ t('topBar.latestFeaturesTitle') }}(<a href="https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md" target="_blank">{{ t('topBar.changelog') }}</a>):
</div>
<ul class="conspicuous feature_list">
<li v-for="feature in features" :key="feature">
@@ -95,17 +112,17 @@
</ul>
</template>
<p>
TG更新通知频道:<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a>
{{ t('topBar.telegramChannel') }}<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a>
</p>
<p>
项目地址:<a
{{ t('topBar.projectUrl') }}<a
class="link"
href="https://github.com/chaos-zhu/easynode"
target="_blank"
>https://github.com/chaos-zhu/easynode</a>
</p>
<div class="about_footer">
<el-button type="info" @click="visible = false">关闭</el-button>
<el-button type="info" @click="visible = false">{{ t('common.close') }}</el-button>
</div>
</div>
</el-dialog>
@@ -128,14 +145,17 @@
<script setup>
import { ref, getCurrentInstance, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { User, Fold, Document } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { User, Fold, Document, Operation } from '@element-plus/icons-vue'
import packageJson from '../../package.json'
import MenuList from './menuList.vue'
import AiButton from './ai-chat/ai-button.vue'
import useMobileWidth from '@/composables/useMobileWidth'
import { setLocale } from '@/i18n'
const { proxy: { $router, $store, $api, $message } } = getCurrentInstance()
const router = useRouter()
const { t, locale } = useI18n()
const visible = ref(false)
const checkVersionErr = ref(false)
const currentVersion = ref(`v${ packageJson.version }`)
@@ -144,6 +164,11 @@ const features = ref([])
const menuCollapse = ref(false)
const discount = ref(false)
const languageOptions = {
'zh-CN': t('topBar.languageNames.zhCN'),
en: t('topBar.languageNames.en')
}
const { isMobileScreen } = useMobileWidth()
const isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
@@ -151,16 +176,21 @@ const user = computed(() => $store.user)
const title = computed(() => $store.title)
const isPlusActive = computed(() => $store.isPlusActive)
const menuPosition = computed(() => $store.menuPosition)
const currentLanguageLabel = computed(() => languageOptions[locale.value] || languageOptions['zh-CN'])
const handleCollapse = () => {
menuCollapse.value = !menuCollapse.value
}
const handleLanguageChange = (lang) => {
setLocale(lang)
}
const handleLogout = async () => {
try {
await $store.removeLoginInfo(true)
} finally {
$message({ type: 'success', message: '已安全退出', center: true })
$message({ type: 'success', message: t('topBar.logoutSuccess'), center: true })
$router.push('/login')
}
}
@@ -178,7 +208,7 @@ async function checkLatestVersionByGitRelease() {
const timeout = 5000
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), timeout)
setTimeout(() => reject(new Error(t('topBar.requestTimeout'))), timeout)
)
const url = `https://api.github.com/repos/chaos-zhu/easynode/releases?ts=${ new Date().getTime() }`
const fetchPromise = fetch(url, {
@@ -188,17 +218,16 @@ async function checkLatestVersionByGitRelease() {
})
const response = await Promise.race([fetchPromise, timeoutPromise,])
if (!response.ok) {
throw new Error('版本信息请求失败: ' + response.statusText)
throw new Error(`${t('topBar.versionRequestFailed')}: ${response.statusText}`)
}
const releases = await response.json()
// console.log('releases:', releases)
const filteredReleases = releases.filter(release => !release.tag_name.startsWith('client'))
if (filteredReleases.length > 0) {
latestVersion.value = filteredReleases[0].tag_name
}
} catch (error) {
checkVersionErr.value = true
console.error('版本信息请求失败:', error.message)
console.error(`${t('topBar.versionRequestFailed')}:`, error.message)
}
}
@@ -207,7 +236,6 @@ async function checkLatestVersionByJson() {
try {
let date = new Date()
let lastGetVersionTime = localStorage.getItem('lastGetVersionTime')
// 10分钟内不重复请求
if (lastGetVersionTime && (date.getTime() - Number(lastGetVersionTime) < 1000 * 60 * 10)) {
let latestVersionFromLocal = localStorage.getItem('latestVersion')
let featuresFromLocal = localStorage.getItem('features')
@@ -219,16 +247,15 @@ async function checkLatestVersionByJson() {
}
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), timeout)
setTimeout(() => reject(new Error(t('topBar.requestTimeout'))), timeout)
)
const url = `https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/refs/heads/main/server/version.json?ts=${ new Date().getTime() }`
const fetchPromise = fetch(url)
const response = await Promise.race([fetchPromise, timeoutPromise,])
if (!response.ok) {
throw new Error('版本信息请求失败: ' + response.statusText)
throw new Error(`${t('topBar.versionRequestFailed')}: ${response.statusText}`)
}
const releases = await response.json()
// console.log('releases:', releases)
if (Array.isArray(releases) && releases.length > 0) {
latestVersion.value = releases[0].version
features.value = releases[0].features
@@ -238,7 +265,7 @@ async function checkLatestVersionByJson() {
}
} catch (error) {
checkVersionErr.value = true
console.error('版本信息请求失败:', error.message)
console.error(`${t('topBar.versionRequestFailed')}:`, error.message)
checkLatestVersionByGitRelease()
}
}
@@ -352,10 +379,24 @@ onBeforeUnmount(() => {
}
}
.language_switch,
.username_wrap {
display: flex;
align-items: center;
cursor: pointer;
}
.language_switch {
margin-right: 15px;
color: var(--el-text-color-regular);
font-size: 14px;
span {
margin-left: 5px;
}
}
.username_wrap {
.username {
cursor: pointer;
margin-left: 5px;
@@ -597,4 +638,4 @@ onBeforeUnmount(() => {
}
}
}
</style>
</style>
+16 -14
View File
@@ -1,27 +1,29 @@
import { ref, onUnmounted, computed, watch } from 'vue'
import { AIChatService } from '@/utils/aiChatService'
import { ElMessage } from 'element-plus'
import i18n from '@/i18n'
import useStore from '@/store'
import $api from '@/api'
import { randomStr } from '@/utils'
// const titlePrompt = '使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,不要加粗,如果没有主题,请直接返回"闲聊"'
const t = i18n.global.t
const defaultChatList = () => {
return [{
id: 1,
content: '您好,我是小助手,有什么可以帮您的吗?',
content: t('aiChat.defaultAssistantMessage'),
role: 'assistant',
timestamp: new Date(),
isStreaming: false,
error: false
},]
}]
}
const defaultCurChat = () => {
return {
id: '',
describe: '新对话',
describe: t('aiChat.newChatDefaultTitle'),
chatList: defaultChatList()
}
}
@@ -54,7 +56,7 @@ export function useAIChat() {
const sendMessage = async (messageContent, model, options = {}) => {
if (!aiService.value) {
error.value = '请先配置API参数'
error.value = t('aiChat.configureApiFirst')
ElMessage.error(error.value)
return
}
@@ -123,13 +125,13 @@ export function useAIChat() {
}
},
onError: (err) => {
error.value = err?.message || err || '连接到 AI 服务时出错'
error.value = err?.message || err || t('aiChat.connectAiServiceError')
loading.value = false
const aiMessage = chatList.value.find(msg => msg.id === aiMessageId)
if (aiMessage) {
aiMessage.error = true
aiMessage.isStreaming = false
aiMessage.content = error.value // 将错误信息设置为消息内容
aiMessage.content = error.value
}
}
}
@@ -145,7 +147,7 @@ export function useAIChat() {
saveChat() // 保存对话
} catch (err) {
error.value = err?.message || err || '发送消息时出错'
error.value = err?.message || err || t('aiChat.sendMessageError')
loading.value = false
isConnecting.value = false
isReasoning.value = false
@@ -153,7 +155,7 @@ export function useAIChat() {
if (aiMessage) {
aiMessage.error = true
aiMessage.isStreaming = false
aiMessage.content = error.value // 将错误信息设置为消息内容
aiMessage.content = error.value
}
}
}
@@ -162,8 +164,8 @@ export function useAIChat() {
if (loading.value) return
chatList.value.length = 1
error.value = null
await saveChat() // 保存清空后的状态
ElMessage.success('清除成功')
await saveChat()
ElMessage.success(t('aiChat.clearSuccess'))
}
const stopGeneration = () => {
@@ -208,7 +210,7 @@ export function useAIChat() {
const removeChat = async (id) => {
await $api.removeChatHistory(id)
ElMessage.success('删除成功')
ElMessage.success(t('common.deleteSuccess'))
await $store.getChatHistory()
if (chatHistory.value.length > 0) {
changeChat(chatHistory.value[0].id)
@@ -219,7 +221,7 @@ export function useAIChat() {
const changeChat = (id) => {
const chatItem = chatHistory.value.find(item => item.id === id)
if (!chatItem) return ElMessage.error('对话id不存在')
if (!chatItem) return ElMessage.error(t('aiChat.chatIdNotFound'))
curChat.value = chatItem
}
@@ -229,7 +231,7 @@ export function useAIChat() {
curChat.value.describe = title
}
const { data } = await $api.saveChatHistory(curChat.value)
if (!data.updateChat) ElMessage.error('对话保存失败')
if (!data.updateChat) ElMessage.error(t('aiChat.saveFailed'))
else {
curChat.value.id = data.updateChat.id
curChat.value.createdAt = data.updateChat.createdAt
+2 -2
View File
@@ -90,7 +90,7 @@ export function useContextMenu() {
container.parentNode.removeChild(container)
}
} catch (error) {
console.warn('清理菜单时出错:', error)
console.warn('Error while cleaning up menu:', error)
}
contextMenuInstance = null
contextMenuApp = null
@@ -120,7 +120,7 @@ export function useContextMenu() {
container.parentNode.removeChild(container)
}
} catch (error) {
console.warn('同步清理菜单时出错:', error)
console.warn('Error while synchronously cleaning up menu:', error)
}
contextMenuInstance = null
@@ -11,6 +11,10 @@
* @param {import('@/composables/useContextMenu').showMenu} options.showMenu - 显示右键菜单的方法
* @param {import('@/utils/enum').terminalStatus} options.terminalStatus - 终端状态枚举
*/
import i18n from '@/i18n'
const t = i18n.global.t
export function useTerminalTabContextMenu({
terminalTabs,
onSuspend,
@@ -31,7 +35,7 @@ export function useTerminalTabContextMenu({
// 挂起终端(仅在已连接状态显示)
if (item.status === terminalStatus.CONNECT_SUCCESS) {
menuItems.push({
label: '挂起',
label: t('terminal.suspend'),
onClick: () => onSuspend(item, index)
})
}
@@ -40,7 +44,7 @@ export function useTerminalTabContextMenu({
const tabs = typeof terminalTabs === 'function' ? terminalTabs() : terminalTabs.value
if (tabs.some(tab => tab.status === terminalStatus.CONNECT_SUCCESS)) {
menuItems.push({
label: '挂起所有',
label: t('terminal.suspendAll'),
onClick: () => onSuspendAll()
})
}
@@ -48,14 +52,14 @@ export function useTerminalTabContextMenu({
// 关闭其他终端
if (tabs.length > 1) {
menuItems.push({
label: '关闭其他',
label: t('terminal.closeOtherSessions'),
onClick: () => onCloseOther(index, item)
})
}
// 关闭所有终端
menuItems.push({
label: '关闭所有',
label: t('terminal.closeAllSessions'),
onClick: () => onCloseAll()
})
+44
View File
@@ -0,0 +1,44 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import en from './locales/en'
const SUPPORT_LOCALES = ['zh-CN', 'en']
const DEFAULT_LOCALE = 'zh-CN'
const normalizeLocale = (locale) => {
if (!locale) return DEFAULT_LOCALE
if (SUPPORT_LOCALES.includes(locale)) return locale
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'
if (locale.toLowerCase().startsWith('en')) return 'en'
return DEFAULT_LOCALE
}
const savedLocale = localStorage.getItem('locale')
const browserLocale = navigator.language
const locale = normalizeLocale(savedLocale || browserLocale)
const messages = {
'zh-CN': zhCN,
en
}
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: DEFAULT_LOCALE,
messages,
missingWarn: import.meta.env.DEV,
fallbackWarn: import.meta.env.DEV
})
export const setLocale = (lang) => {
const nextLocale = normalizeLocale(lang)
i18n.global.locale.value = nextLocale
localStorage.setItem('locale', nextLocale)
document.cookie = `locale=${nextLocale}; path=/; max-age=31536000`
}
export const getLocale = () => i18n.global.locale.value
export const getElementLocaleKey = () => normalizeLocale(i18n.global.locale.value)
export default i18n
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,7 @@ import globalComponents from './plugins/components'
import axios from '@/utils/axios'
import api from './api'
import App from './app.vue'
import i18n from './i18n'
import './assets/scss/reset.scss'
import './assets/scss/global.scss'
import './assets/scss/mobile.scss'
@@ -15,6 +16,7 @@ import './assets/scss/mobile.scss'
const app = createApp(App)
elementPlugins(app)
globalComponents(app)
app.use(i18n)
app.use(createPinia())
app.use(router)
+24 -18
View File
@@ -1,5 +1,9 @@
import i18n from '@/i18n'
import ping from '../utils/ping'
const t = i18n.global.t
const getLocale = () => i18n.global.locale.value
export default {
toFixed(value, count = 1) {
value = Number(value)
@@ -9,14 +13,15 @@ export default {
let day = Math.floor(second / 60 / 60 / 24)
let hour = Math.floor(second / 60 / 60 % 24)
let minute = Math.floor(second / 60 % 60)
let remainSecond = Math.floor(second % 60)
if (target === 'day') {
return `${ day }天`
return t('tools.dayOnly', { day })
} else if (target === 'hour') {
return `${ day }天${ hour }时`
return t('tools.dayHour', { day, hour })
} else if (target === 'minute') {
return `${ day }天${ hour }时${ minute }分`
return t('tools.dayHourMinute', { day, hour, minute })
}
return `${ day }天${ hour }时${ minute }分${ second }秒`
return t('tools.dayHourMinuteSecond', { day, hour, minute, second: remainSecond })
},
formatNetSpeed(netSpeedMB) {
netSpeedMB = Number(netSpeedMB) || 0
@@ -27,21 +32,22 @@ export default {
formatTimestamp: (timestamp, format = 'time', afterSeparator = ':') => {
if (typeof(timestamp) !== 'number') return '--'
let date = new Date(timestamp)
let padZero = (num) => String(num).padStart(2, '0')
let year = date.getFullYear()
let mounth = padZero(date.getMonth() + 1)
let day = padZero(date.getDate())
let hours = padZero(date.getHours())
let minute = padZero(date.getMinutes())
let second = padZero(date.getSeconds())
switch (format) {
case 'date':
return `${ year }-${ mounth }-${ day }`
case 'time':
return `${ year }-${ mounth }-${ day } ${ hours }${ afterSeparator }${ minute }${ afterSeparator }${ second }`
default:
return `${ year }-${ mounth }-${ day } ${ hours }${ afterSeparator }${ minute }${ afterSeparator }${ second }`
let locale = getLocale() === 'en' ? 'en-US' : 'zh-CN'
if (format === 'date') {
return date.toLocaleDateString(locale)
}
if (format === 'time') {
const [datePart] = date.toLocaleDateString(locale).split(',')
const timePart = [date.getHours(), date.getMinutes(), date.getSeconds()]
.map(num => String(num).padStart(2, '0'))
.join(afterSeparator)
return `${ datePart } ${ timePart }`
}
const [datePart] = date.toLocaleDateString(locale).split(',')
const timePart = [date.getHours(), date.getMinutes(), date.getSeconds()]
.map(num => String(num).padStart(2, '0'))
.join(afterSeparator)
return `${ datePart } ${ timePart }`
},
ping
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import dayjs from 'dayjs'
import i18n from '@/i18n'
import $api from '@/api'
import { isValidDate } from '@/utils'
@@ -101,7 +102,7 @@ const useStore = defineStore('global', {
await $api.revokeLoginSid(this.deviceId)
}
} catch (err) {
console.error('注销登录凭证失败: ', err.message)
console.error('Failed to revoke login credential:', err.message)
} finally {
localStorage.removeItem('token')
sessionStorage.removeItem('token')
@@ -181,7 +182,7 @@ const useStore = defineStore('global', {
const isPlusActive = new Date(plusInfo.expiryDate) > new Date()
this.$patch({ isPlusActive })
plusInfo.expiryDate = dayjs(plusInfo.expiryDate).format('YYYY-MM-DD')
plusInfo.expiryDate?.startsWith('9999') && (plusInfo.expiryDate = '永久授权')
plusInfo.expiryDate?.startsWith('9999') && (plusInfo.expiryDate = i18n.global.t('settings.plus.permanentLicense'))
this.$patch({ plusInfo })
} else {
this.$patch({ isPlusActive: false })
@@ -262,7 +263,7 @@ const useStore = defineStore('global', {
} else {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
const systemTheme = prefersDarkScheme.matches
console.log('当前系统使用的是深色模式:', systemTheme ? '是' : '否')
console.log('System theme is dark mode:', systemTheme ? 'yes' : 'no')
isDark = systemTheme
}
this.setTheme(isDark, false)
+17 -13
View File
@@ -1,3 +1,7 @@
import i18n from '@/i18n'
const t = i18n.global.t
export class AIChatService {
constructor(baseUrl, apiKey) {
this.baseUrl = baseUrl
@@ -130,7 +134,7 @@ export class AIChatService {
}
// ======
} catch (error) {
console.error('解析消息失败:', error)
console.error('Failed to parse AI stream message:', error)
if (callbacks.onError) {
callbacks.onError(error)
}
@@ -139,9 +143,9 @@ export class AIChatService {
}
}
} catch (error) {
console.error('连接错误:', error.message)
console.error('AI connection error:', error.message)
if (error.message === 'BodyStreamBuffer was aborted') {
callbacks.onMessage('用户取消请求')
callbacks.onMessage(t('aiChat.requestCancelled'))
this.closeConnection()
return
}
@@ -153,13 +157,13 @@ export class AIChatService {
async generateTitle(messages, model, options = {}) {
if (!messages || messages.length === 0) {
return '闲聊'
return t('aiChat.fallbackTitle')
}
const titleMessages = JSON.parse(JSON.stringify(messages)).splice(1) // 移除打招呼的内容
const titleMessages = JSON.parse(JSON.stringify(messages)).splice(1)
titleMessages.push({
role: 'user',
content: '使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,不要加粗,如果没有主题,请直接返回"闲聊"'
content: t('aiChat.titlePrompt')
})
const defaultOptions = {
@@ -195,20 +199,20 @@ export class AIChatService {
const errorData = await response.json()
errorMessage = errorData.error?.message || errorMessage
} catch (e) {
// 如果无法解析错误响应,使用默认错误信息
// ignore invalid error payload
}
console.error('生成标题失败:', errorMessage)
return '闲聊'
console.error('Failed to generate title:', errorMessage)
return t('aiChat.fallbackTitle')
}
const data = await response.json()
if (data.choices && data.choices[0] && data.choices[0].message) {
return data.choices[0].message.content.trim() || '闲聊'
return data.choices[0].message.content.trim() || t('aiChat.fallbackTitle')
}
return '闲聊'
return t('aiChat.fallbackTitle')
} catch (error) {
console.error('生成标题错误:', error)
return '闲聊'
console.error('Title generation error:', error)
return t('aiChat.fallbackTitle')
}
}
+8 -5
View File
@@ -2,18 +2,21 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
import useStore from '../store'
import i18n from '@/i18n'
axios.defaults.timeout = 30 * 1000
axios.defaults.withCredentials = true
axios.defaults.baseURL = process.env.serviceApiPrefix || '/api/v1'
const instance = axios.create()
const { t } = i18n.global
instance.interceptors.request.use((config) => {
config.headers.token = useStore().token
config.headers['Accept-Language'] = i18n.global.locale.value
return config
}, (error) => {
ElMessage.error({ message: '请求超时!' })
ElMessage.error({ message: t('topBar.requestTimeout') })
return Promise.reject(error)
})
@@ -22,7 +25,7 @@ instance.interceptors.response.use((response) => {
}, (error) => {
let { response } = error
if (error?.message?.includes('timeout')) {
ElMessage({ message: '请求超时', type: 'error', center: true })
ElMessage({ message: t('topBar.requestTimeout'), type: 'error', center: true })
return Promise.reject(error)
}
switch (response?.data?.status) {
@@ -30,16 +33,16 @@ instance.interceptors.response.use((response) => {
router.push('login')
return Promise.reject(error)
case 403:
ElMessage({ message: `${ response?.data?.msg || '登录错误' }`, type: 'error', center: true })
ElMessage({ message: `${ response?.data?.msg || t('login.loginFailed') }`, type: 'error', center: true })
router.push('login')
return Promise.reject(error)
}
switch (response?.status) {
case 404:
ElMessage({ message: '404 Not Found', type: 'error', center: true })
ElMessage({ message: t('common.notFound'), type: 'error', center: true })
return Promise.reject(error)
}
ElMessage({ message: response?.data.msg || error?.message || '网络错误', type: 'error', center: true })
ElMessage({ message: response?.data.msg || error?.message || t('common.networkError'), type: 'error', center: true })
return Promise.reject(error)
})
+11 -8
View File
@@ -5,6 +5,9 @@
*/
import { ElMessage, ElMessageBox } from 'element-plus'
import i18n from '@/i18n'
const { t } = i18n.global
/**
* 检测是否支持现代剪贴板API
@@ -32,7 +35,7 @@ function copyWithLegacyAPI(text) {
const successful = document.execCommand('copy')
return successful
} catch (err) {
console.error('复制失败:', err)
console.error('Copy failed:', err)
return false
} finally {
document.body.removeChild(textarea)
@@ -47,7 +50,7 @@ function copyWithLegacyAPI(text) {
*/
export async function copyText(text, showMessage = true) {
if (!text) {
if (showMessage) ElMessage.warning('复制内容为空')
if (showMessage) ElMessage.warning(t('common.emptyCopyContent'))
return false
}
@@ -59,7 +62,7 @@ export async function copyText(text, showMessage = true) {
await navigator.clipboard.writeText(text)
success = true
} catch (err) {
console.warn('现代API复制失败,降级到传统方法:', err)
console.warn('Modern clipboard API copy failed, falling back to legacy method:', err)
success = copyWithLegacyAPI(text)
}
} else {
@@ -68,7 +71,7 @@ export async function copyText(text, showMessage = true) {
}
if (showMessage) {
success ? ElMessage.success('复制成功') : ElMessage.error('复制失败')
success ? ElMessage.success(t('common.copySuccess')) : ElMessage.error(t('common.copyFailed'))
}
return success
@@ -82,13 +85,13 @@ export async function copyText(text, showMessage = true) {
*/
export async function pasteText(showMessage = false) {
if (!isModernAPISupported()) {
ElMessageBox.confirm('浏览器安全限制,不支持剪贴板读取,需配置https', '提示', {
confirmButtonText: '好的',
ElMessageBox.confirm(t('common.clipboardReadRequiresHttps'), t('settings.common.tip'), {
confirmButtonText: t('common.gotIt'),
showCancelButton: false,
type: 'warning',
dangerouslyUseHTMLString: true
}).catch(() => {})
const errorMsg = 'HTTP环境不支持读取剪贴板,请使用 Ctrl+V 或右键粘贴'
const errorMsg = t('common.httpClipboardReadUnsupported')
throw new Error(errorMsg)
}
@@ -96,7 +99,7 @@ export async function pasteText(showMessage = false) {
const text = await navigator.clipboard.readText()
return text
} catch (err) {
if (showMessage) ElMessage.error('读取剪贴板失败')
if (showMessage) ElMessage.error(t('common.readClipboardFailed'))
throw err
}
}
+18 -15
View File
@@ -1,4 +1,7 @@
// 终端连接状态
import i18n from '@/i18n'
const t = i18n.global.t
export const terminalStatus = {
CONNECT_READY: 'connect_ready',
CONNECTING: 'connecting',
@@ -9,12 +12,12 @@ export const terminalStatus = {
}
export const terminalStatusList = [
{ value: terminalStatus.CONNECT_READY, label: '待连接', color: 'gray' },
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
{ value: terminalStatus.SUSPENDED, label: '已挂起', color: '#909399' }, // 新增
{ value: terminalStatus.RESUMING, label: '恢复中', color: '#409EFF' }, // 新增
{ value: terminalStatus.CONNECT_READY, label: t('enum.terminalStatus.ready'), color: 'gray' },
{ value: terminalStatus.CONNECTING, label: t('enum.terminalStatus.connecting'), color: '#FFA500' },
{ value: terminalStatus.CONNECT_FAIL, label: t('enum.terminalStatus.connectFailed'), color: '#DC3545' },
{ value: terminalStatus.CONNECT_SUCCESS, label: t('enum.terminalStatus.connected'), color: '#28A745' },
{ value: terminalStatus.SUSPENDED, label: t('enum.terminalStatus.suspended'), color: '#909399' },
{ value: terminalStatus.RESUMING, label: t('enum.terminalStatus.resuming'), color: '#409EFF' },
]
// RDP连接状态
@@ -30,14 +33,14 @@ export const rdpStatus = {
}
export const rdpStatusList = [
{ value: rdpStatus.IDLE, label: '准备连接', color: '#909399' },
{ value: rdpStatus.CONNECTING, label: '正在连接...', color: '#E6A23C' },
{ value: rdpStatus.WAITING, label: '等待响应...', color: '#409EFF' },
{ value: rdpStatus.CONNECTED, label: '已连接', color: '#67C23A' },
{ value: rdpStatus.DISCONNECTING, label: '正在断开...', color: '#E6A23C' },
{ value: rdpStatus.DISCONNECTED, label: '已断开', color: '#909399' },
{ value: rdpStatus.TIMEOUT, label: '连接超时', color: '#F56C6C' },
{ value: rdpStatus.ERROR, label: '连接错误', color: '#F56C6C' },
{ value: rdpStatus.IDLE, label: t('enum.rdpStatus.idle'), color: '#909399' },
{ value: rdpStatus.CONNECTING, label: t('enum.rdpStatus.connecting'), color: '#E6A23C' },
{ value: rdpStatus.WAITING, label: t('enum.rdpStatus.waiting'), color: '#409EFF' },
{ value: rdpStatus.CONNECTED, label: t('enum.rdpStatus.connected'), color: '#67C23A' },
{ value: rdpStatus.DISCONNECTING, label: t('enum.rdpStatus.disconnecting'), color: '#E6A23C' },
{ value: rdpStatus.DISCONNECTED, label: t('enum.rdpStatus.disconnected'), color: '#909399' },
{ value: rdpStatus.TIMEOUT, label: t('enum.rdpStatus.timeout'), color: '#F56C6C' },
{ value: rdpStatus.ERROR, label: t('enum.rdpStatus.error'), color: '#F56C6C' },
]
export const virtualKeyType = {
+31 -27
View File
@@ -1,3 +1,7 @@
import i18n from '@/i18n'
const t = i18n.global.t
/**
* 终端高亮器和默认的规则定义
*/
@@ -26,7 +30,7 @@ export const DEFAULT_COLOR_MAPPING = {
export const HIGHLIGHT_RULES = {
// 规则1 - 错误类关键词
rule1: {
title: '错误类关键词',
title: t('highlighter.rule1Title'),
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,
@@ -40,7 +44,7 @@ export const HIGHLIGHT_RULES = {
// 规则2 - 警告类关键词
rule2: {
title: '警告类关键词',
title: t('highlighter.rule2Title'),
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,
@@ -54,7 +58,7 @@ export const HIGHLIGHT_RULES = {
// 规则3 - 成功类关键词
rule3: {
title: '成功类关键词',
title: t('highlighter.rule3Title'),
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,
@@ -68,7 +72,7 @@ export const HIGHLIGHT_RULES = {
// 规则4 - 信息类关键词
rule4: {
title: '信息类关键词',
title: t('highlighter.rule4Title'),
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,
@@ -82,7 +86,7 @@ export const HIGHLIGHT_RULES = {
// 规则5 - 网络地址
rule5: {
title: 'IP地址和端口',
title: t('highlighter.rule5Title'),
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,
@@ -96,7 +100,7 @@ export const HIGHLIGHT_RULES = {
// 规则6 - URL链接、邮箱地址、文件路径
rule6: {
title: 'URL链接和路径',
title: t('highlighter.rule6Title'),
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,
@@ -110,7 +114,7 @@ export const HIGHLIGHT_RULES = {
// 规则7 - 日期时间格式
rule7: {
title: '日期时间',
title: t('highlighter.rule7Title'),
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,
@@ -124,7 +128,7 @@ export const HIGHLIGHT_RULES = {
// 规则8 - 带单位的数据
rule8: {
title: '数据和单位',
title: t('highlighter.rule8Title'),
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,
@@ -157,10 +161,10 @@ export class TerminalHighlighter {
this.mergedPatterns = compiled.mergedPatterns
if (this.debugMode) {
console.log('已初始化终端高亮器')
console.log('Terminal highlighter initialized')
const totalRules = this.mergedPatterns.reduce((sum, group) =>
sum + Object.keys(group.ruleMap).length, 0)
console.log('已启用规则数:', totalRules)
console.log('Enabled rule count:', totalRules)
}
}
@@ -180,7 +184,7 @@ export class TerminalHighlighter {
// 使用通用方法处理pattern
const patternSource = this.extractPatternSource(rule)
if (!patternSource) {
console.warn(`跳过无效的规则 ${ name }:`, rule.pattern)
console.warn(`Skipping invalid rule ${ name }:`, rule.pattern)
continue
}
@@ -227,9 +231,9 @@ export class TerminalHighlighter {
}
if (this.debugMode) {
console.log('合并正则分组数:', mergedPatterns.length)
console.log('Merged regex group count:', mergedPatterns.length)
mergedPatterns.forEach(group => {
console.log(` flags=${ group.flags }, 规则数=${ Object.keys(group.ruleMap).length }`)
console.log(` flags=${ group.flags }, rule count=${ Object.keys(group.ruleMap).length }`)
})
}
@@ -267,7 +271,7 @@ export class TerminalHighlighter {
if (this.debugMode) {
const totalRules = this.mergedPatterns.reduce((sum, group) =>
sum + Object.keys(group.ruleMap).length, 0)
console.log('规则已更新,启用规则数:', totalRules)
console.log('Rules updated, enabled rule count:', totalRules)
}
}
@@ -280,7 +284,7 @@ export class TerminalHighlighter {
// 跳过空白、极短文本、纯符号文本
if (this.shouldSkip(text)) {
if (this.debugMode) {
console.log('跳过特殊文本:', JSON.stringify(text))
console.log('Skipping special text:', JSON.stringify(text))
}
return text
}
@@ -290,7 +294,7 @@ export class TerminalHighlighter {
// 如果文本主要是控制序列(如光标移动、清屏等),则跳过
if (this.isControlSequenceOnly(text)) {
if (this.debugMode) {
console.log('跳过控制序列文本:', text.replace(/\x1b/g, '\\x1b'))
console.log('Skipping control-sequence-only text:', text.replace(/\x1b/g, '\\x1b'))
}
return text
}
@@ -298,7 +302,7 @@ export class TerminalHighlighter {
// 包含复杂控制序列(如OSC标题更新)时直接旁路,避免破坏提示符刷新
if (this.hasComplexControlSequences(text)) {
if (this.debugMode) {
console.log('检测到复杂控制序列,跳过高亮:', text.replace(/\x1b/g, '\\x1b'))
console.log('Detected complex control sequence, skipping highlight:', text.replace(/\x1b/g, '\\x1b'))
}
return text
}
@@ -307,13 +311,13 @@ export class TerminalHighlighter {
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'))
console.log('Original text (with ANSI):', text.replace(/\x1b/g, '\\x1b'))
console.log('Highlighted text:', result.replace(/\x1b/g, '\\x1b'))
}
return result
} catch (error) {
if (this.debugMode) {
console.log('ANSI文本处理失败,保持原样:', text.replace(/\x1b/g, '\\x1b'))
console.log('Failed to process ANSI text, keeping original:', text.replace(/\x1b/g, '\\x1b'))
}
return text
}
@@ -323,13 +327,13 @@ export class TerminalHighlighter {
const highlightedText = this.applyRules(text)
if (this.debugMode && text !== highlightedText) {
console.log('原始文本:', JSON.stringify(text))
console.log('高亮后:', highlightedText.replace(/\x1b/g, '\\x1b'))
console.log('Original text:', JSON.stringify(text))
console.log('Highlighted text:', highlightedText.replace(/\x1b/g, '\\x1b'))
}
return highlightedText
} catch (error) {
console.error('高亮处理失败:', error)
console.error('Highlight processing failed:', error)
return text
}
}
@@ -532,9 +536,9 @@ export class TerminalHighlighter {
setDebugMode(debugMode) {
this.debugMode = debugMode
if (debugMode) {
console.log('终端高亮调试模式已启用')
console.log('Terminal highlighter debug mode enabled')
} else {
console.log('终端高亮调试模式已禁用')
console.log('Terminal highlighter debug mode disabled')
}
}
@@ -544,7 +548,7 @@ export class TerminalHighlighter {
// 使用通用方法获取pattern源码
const patternSource = this.extractPatternSource(rule)
if (!patternSource) {
return '正则表达式格式错误'
return t('highlighter.invalidRegexFormat')
}
const flags = rule.flags || 'gi'
@@ -554,7 +558,7 @@ export class TerminalHighlighter {
return this.applyHtmlStyle(match, rule)
})
} catch (error) {
return `正则表达式错误: ${ error.message }`
return t('highlighter.regexError', { message: error.message })
}
}
+6 -4
View File
@@ -2,8 +2,11 @@ import { reactive } from 'vue'
import JSRsaEncrypt from 'jsencrypt'
import CryptoJS from 'crypto-js'
import socketIo from 'socket.io-client'
import i18n from '@/i18n'
import useStore from '../store'
const t = i18n.global.t
export const EventBus = reactive({})
EventBus.$emit = (event, data) => {
@@ -39,7 +42,7 @@ export const randomStr = (e) =>{
export const jwtExpireToTimestamp = (str) => {
const match = /^(\d+)([smhd])$/.exec(str)
if (!match) throw new Error('jwt过期格式错误,必须是 [xx]s / [xx]h / [xx]d')
if (!match) throw new Error(t('utils.jwtExpireFormatError'))
const n = Number(match[1])
const unit = match[2]
@@ -137,12 +140,11 @@ export const sortDirTree = (tree = []) => {
}
export const downloadFile = ({ buffer, name }) => {
let contentUrl = window.URL.createObjectURL(new Blob([buffer,])) // params:object 可选: File对象、Blob对象、MediaSource对象。
let contentUrl = window.URL.createObjectURL(new Blob([buffer,]))
let link = document.createElement('a')
link.style.display = 'none'
link.href = contentUrl
console.log(name)
link.setAttribute('download', name) // 文件名称
link.setAttribute('download', name)
document.body.appendChild(link)
link.click()
setTimeout(() => {
+35 -33
View File
@@ -1,19 +1,19 @@
<template>
<div class="credentials_container">
<div class="header">
<el-button type="primary" @click="addCredentials">添加凭证</el-button>
<el-button type="primary" @click="addCredentials">{{ t('credentials.add') }}</el-button>
</div>
<el-table v-loading="loading" :data="sshList">
<el-table-column prop="name" label="名称" />
<el-table-column prop="authType" label="类型">
<el-table-column prop="name" :label="t('credentials.name')" />
<el-table-column prop="authType" :label="t('credentials.type')">
<template #default="{ row }">
{{ row.authType === 'privateKey' ? '密钥' : '密码' }}
{{ row.authType === 'privateKey' ? t('credentials.key') : t('credentials.password') }}
</template>
</el-table-column>
<el-table-column width="160px" label="操作">
<el-table-column width="160px" :label="t('credentials.actions')">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="removeSSH(row)">删除</el-button>
<el-button type="primary" @click="handleChange(row)">{{ t('credentials.edit') }}</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="removeSSH(row)">{{ t('credentials.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -21,7 +21,7 @@
v-model="sshFormVisible"
width="600px"
top="150px"
:title="isModify ? '修改凭证' : '添加凭证'"
:title="isModify ? t('credentials.editTitle') : t('credentials.addTitle')"
:close-on-click-modal="false"
@close="clearFormInfo"
>
@@ -34,7 +34,7 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="凭证名称" prop="name">
<el-form-item :label="t('credentials.credentialName')" prop="name">
<el-input
v-model="sshForm.name"
clearable
@@ -42,13 +42,13 @@
autocomplete="off"
/>
</el-form-item>
<el-form-item label="认证方式" prop="type">
<el-radio v-model="sshForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model="sshForm.authType" value="password">密码</el-radio>
<el-form-item :label="t('credentials.authType')" prop="type">
<el-radio v-model="sshForm.authType" value="privateKey">{{ t('credentials.key') }}</el-radio>
<el-radio v-model="sshForm.authType" value="password">{{ t('credentials.password') }}</el-radio>
</el-form-item>
<el-form-item v-if="sshForm.authType === 'privateKey'" prop="privateKey" label="密钥">
<el-form-item v-if="sshForm.authType === 'privateKey'" prop="privateKey" :label="t('credentials.key')">
<el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥...
{{ t('credentials.localPrivateKey') }}
</el-button>
<input
ref="privateKeyRef"
@@ -67,18 +67,18 @@
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item>
<el-form-item v-if="sshForm.authType === 'privateKey' && showOpenSSHKeyField" label="私钥密码">
<el-form-item v-if="sshForm.authType === 'privateKey' && showOpenSSHKeyField" :label="t('credentials.privateKeyPassword')">
<el-input
v-model="sshForm.openSSHKeyPassword"
type="password"
:disabled="!isPlusActive"
placeholder="请输入openssh私钥密码(没有密码请留空)"
:placeholder="t('credentials.opensshPasswordPlaceholder')"
show-password
autocomplete="off"
clearable
/>
</el-form-item>
<el-form-item v-if="sshForm.authType === 'password'" prop="password" label="密码">
<el-form-item v-if="sshForm.authType === 'password'" prop="password" :label="t('credentials.password')">
<el-input
v-model="sshForm.password"
type="text"
@@ -88,29 +88,29 @@
/>
<div v-if="passwordHasSpace" class="password-warning">
<el-icon><WarningFilled /></el-icon>
<span>密码中包含空格字符</span>
<span>{{ t('credentials.passwordContainsSpace') }}</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<span>
<el-button @click="sshFormVisible = false">关闭</el-button>
<el-button type="primary" @click="updateForm">{{ isModify ? '修改' : '添加' }}</el-button>
<el-button @click="sshFormVisible = false">{{ t('credentials.close') }}</el-button>
<el-button type="primary" @click="updateForm">{{ isModify ? t('credentials.edit') : t('credentials.add') }}</el-button>
</span>
</template>
</el-dialog>
<el-dialog
v-model="keyPasswordVisible"
title="输入密钥密码"
:title="t('credentials.inputKeyPasswordTitle')"
width="400px"
:close-on-click-modal="false"
>
<el-form @submit.prevent>
<el-form-item label="密码">
<el-form-item :label="t('credentials.password')">
<el-input
v-model="keyPassword"
type="password"
placeholder="请输入密钥密码"
:placeholder="t('credentials.inputKeyPasswordPlaceholder')"
show-password
autocomplete="off"
clearable
@@ -120,9 +120,9 @@
</el-form>
<template #footer>
<span>
<el-button @click="keyPasswordVisible = false">取消</el-button>
<el-button @click="keyPasswordVisible = false">{{ t('common.cancel') }}</el-button>
<PlusSupportTip>
<el-button type="primary" :disabled="!isPlusActive" @click="handleDecryptKey">确认</el-button>
<el-button type="primary" :disabled="!isPlusActive" @click="handleDecryptKey">{{ t('common.confirm') }}</el-button>
</PlusSupportTip>
</span>
</template>
@@ -132,11 +132,13 @@
<script setup>
import { ref, reactive, computed, nextTick, getCurrentInstance, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
import { WarningFilled } from '@element-plus/icons-vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const sshFormVisible = ref(false)
@@ -151,7 +153,7 @@ const sshForm = reactive({
const rules = computed(() => {
return {
name: { required: true, message: '需输入凭证名称', trigger: 'change' },
name: { required: true, message: t('credentials.validation.nameRequired'), trigger: 'change' },
password: [{ required: !isModify.value && sshForm.authType === 'password', trigger: 'change' },],
privateKey: [{ required: !isModify.value && sshForm.authType === 'privateKey', trigger: 'change' },]
}
@@ -196,7 +198,7 @@ const updateForm = () => {
}
sshFormVisible.value = false
await $store.getSSHList()
$message.success('success')
$message.success(t('credentials.messages.success'))
})
}
@@ -205,16 +207,16 @@ const clearFormInfo = () => {
}
const removeSSH = ({ id, name }) => {
$messageBox.confirm(`确认删除该凭证:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('credentials.deleteConfirm', { name }), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
await $api.removeSSH(id)
await $store.getSSHList()
await $store.getHostList()
$message.success('success')
$message.success(t('credentials.messages.success'))
})
}
@@ -254,7 +256,7 @@ const handleSelectPrivateKeyFile = (event) => {
}
const handleDecryptKey = async () => {
if (!keyPassword.value) return $message.error('请输入密钥密码')
if (!keyPassword.value) return $message.error(t('credentials.messages.inputKeyPassword'))
const { data } = await $api.decryptPrivateKey({
privateKey: tempPrivateKey.value,
password: keyPassword.value
@@ -263,7 +265,7 @@ const handleDecryptKey = async () => {
keyPasswordVisible.value = false
keyPassword.value = ''
tempPrivateKey.value = ''
$message.success('密钥解密成功')
$message.success(t('credentials.decryptSuccess'))
}
</script>
+18 -16
View File
@@ -5,9 +5,9 @@
<div class="toolbar-left">
<div class="transfer-method-info">
<el-tag type="success" size="large">
在传输前需在服务器上安装好
{{ t('fileTransfer.installHint') }}
<el-tooltip
content="问AI?"
:content="t('fileTransfer.askAi')"
raw-content
>
<span class="link" @click="handleAskAI">sshpass、rsync</span>
@@ -28,7 +28,7 @@
:icon="List"
@click="showTaskManager"
>
任务管理
{{ t('fileTransfer.taskManager') }}
<el-badge
v-if="activeTaskCount > 0"
:value="activeTaskCount"
@@ -37,7 +37,7 @@
</el-button>
<el-button @click="showTransferOptions = !showTransferOptions">
<el-icon><Setting /></el-icon>
传输选项
{{ t('fileTransfer.transferOptions') }}
</el-button>
</div>
</div>
@@ -46,16 +46,16 @@
<el-collapse-transition>
<div v-if="showTransferOptions" class="transfer-options">
<div class="options-content">
<h4>传输选项</h4>
<h4>{{ t('fileTransfer.transferOptionsTitle') }}</h4>
<div class="options-grid">
<el-checkbox v-model="transferOptions.delete">
删除目标多余文件 (--delete)
{{ t('fileTransfer.deleteExtraFiles') }}
</el-checkbox>
<el-checkbox v-model="transferOptions.partial">
支持断点续传 (--partial)
{{ t('fileTransfer.resumeTransfer') }}
</el-checkbox>
<el-checkbox v-model="transferOptions.compress">
启用压缩传输 (-z)
{{ t('fileTransfer.enableCompression') }}
</el-checkbox>
</div>
</div>
@@ -84,7 +84,7 @@
:loading="isTransferring"
@click="transferToRight"
>
传输
{{ t('fileTransfer.transfer') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
@@ -97,7 +97,7 @@
@click="transferToLeft"
>
<el-icon><ArrowLeft /></el-icon>
传输
{{ t('fileTransfer.transfer') }}
</el-button>
</div>
</div>
@@ -116,7 +116,7 @@
<!-- 任务管理器对话框 -->
<el-dialog
v-model="showTaskDialog"
title="传输任务管理"
:title="t('fileTransfer.taskDialogTitle')"
width="80%"
:close-on-click-modal="false"
>
@@ -129,8 +129,8 @@
/>
<template #footer>
<el-button @click="showTaskDialog = false">关闭</el-button>
<el-button type="danger" @click="clearCompletedTasks">清空任务列表</el-button>
<el-button @click="showTaskDialog = false">{{ t('common.close') }}</el-button>
<el-button type="danger" @click="clearCompletedTasks">{{ t('fileTransfer.clearTaskList') }}</el-button>
</template>
</el-dialog>
</div>
@@ -138,6 +138,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { List, Setting, ArrowRight, ArrowLeft } from '@element-plus/icons-vue'
import SftpPanel from '@/components/file-transfer/sftp-panel.vue'
import TransferTaskManager from '@/components/file-transfer/transfer-task-manager.vue'
@@ -146,6 +147,7 @@ import { EventBus, generateSocketInstance } from '@/utils'
defineEmits(['exec-script',])
const { proxy: { $message, $router } } = getCurrentInstance()
const { t } = useI18n()
// 传输配置(仅支持Rsync)
const transferOptions = ref({
@@ -208,7 +210,7 @@ const initializeSocket = () => {
socket.value = generateSocketInstance('/file-transfer')
socket.value.on('connect', () => {
console.log('文件传输WebSocket已连接')
console.log(t('fileTransfer.websocketConnected'))
refreshTasks()
})
@@ -281,7 +283,7 @@ const performTransfer = (fromSide, toSide) => {
const selectedFiles = fromPanel.getSelectedFiles()
if (!selectedFiles || selectedFiles.length === 0) {
$message.warning('请先选择要传输的文件或文件夹')
$message.warning(t('fileTransfer.selectFilesFirst'))
return
}
@@ -387,7 +389,7 @@ const refreshRightPanel = () => {
}
const handleAskAI = () => {
EventBus.$emit('sendToAIInput', '如何在不同的Linux发行版中安装sshpass与rsync?')
EventBus.$emit('sendToAIInput', t('fileTransfer.askAiPrompt'))
}
</script>
+23 -21
View File
@@ -5,7 +5,7 @@
<h2>EasyNode-{{ version }}</h2>
</div>
<div v-if="notKey">
<el-alert title="Error: 用于加密的公钥获取失败,请尝试重新启动或部署服务" type="error" show-icon />
<el-alert :title="t('login.publicKeyFetchFailed')" type="error" show-icon />
</div>
<div v-else>
<el-form
@@ -18,7 +18,7 @@
label-width="90px"
label-position="top"
>
<el-form-item prop="loginName" label="用户名">
<el-form-item prop="loginName" :label="t('login.username')">
<el-input
v-model.trim="loginForm.loginName"
type="text"
@@ -29,7 +29,7 @@
autofocus
/>
</el-form-item>
<el-form-item prop="pwd" label="密码">
<el-form-item prop="pwd" :label="t('login.password')">
<el-input
v-model.trim="loginForm.pwd"
type="password"
@@ -41,14 +41,14 @@
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item v-show="false" prop="pwd" label="密码">
<el-form-item v-show="false" prop="pwd" :label="t('login.password')">
<el-input v-model.trim="loginForm.pwd" />
</el-form-item>
<el-form-item prop="mfa2Token" label="MFA2验证码">
<el-form-item prop="mfa2Token" :label="t('login.mfa2Code')">
<el-input
v-model="loginForm.mfa2Token"
type="text"
placeholder="MFA2应用上的6位数字(未设置可忽略)"
:placeholder="t('login.mfa2Placeholder')"
autocomplete="off"
:trigger-on-focus="false"
clearable
@@ -56,12 +56,12 @@
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="jwtExpires" label="登录有效期">
<el-form-item prop="jwtExpires" :label="t('login.loginValidPeriod')">
<el-radio-group v-model="expireTime" class="login-indate">
<el-radio :value="expireEnum.ONE_SESSION">临时</el-radio>
<el-radio :value="expireEnum.CURRENT_DAY">当天</el-radio>
<el-radio :value="expireEnum.THREE_DAY">三天</el-radio>
<el-radio :value="expireEnum.SEVEN_DAY">七天</el-radio>
<el-radio :value="expireEnum.ONE_SESSION">{{ t('login.temporary') }}</el-radio>
<el-radio :value="expireEnum.CURRENT_DAY">{{ t('login.today') }}</el-radio>
<el-radio :value="expireEnum.THREE_DAY">{{ t('login.threeDays') }}</el-radio>
<el-radio :value="expireEnum.SEVEN_DAY">{{ t('login.sevenDays') }}</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
@@ -73,7 +73,7 @@
:loading="loading"
@click="handleLogin"
>
登录
{{ t('login.login') }}
</el-button>
</div>
</div>
@@ -83,11 +83,13 @@
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { RSAEncrypt, jwtExpireToTimestamp } from '@utils/index.js'
import Warn from './warn.vue'
import { version } from '../../../package.json'
const { proxy: { $store, $api, $message, $router, $messageBox } } = getCurrentInstance()
const { t } = useI18n()
const expireEnum = reactive({
ONE_SESSION: 'one_session',
@@ -108,9 +110,9 @@ const loginForm = reactive({
mfa2Token: ''
})
const rules = reactive({
loginName: { required: true, message: '需输入用户名', trigger: 'change' },
pwd: { required: true, message: '需输入密码', trigger: 'change' },
mfa2Token: { required: false, message: '需输入MFA2验证码', trigger: 'change' }
loginName: { required: true, message: t('login.validation.usernameRequired'), trigger: 'change' },
pwd: { required: true, message: t('login.validation.passwordRequired'), trigger: 'change' },
mfa2Token: { required: false, message: t('login.validation.mfa2Required'), trigger: 'change' }
})
const handleLogin = () => {
@@ -132,22 +134,22 @@ const handleLogin = () => {
}
const jwtExpireAt = jwtExpireToTimestamp(jwtExpires)
const ciphertext = RSAEncrypt(pwd)
if (ciphertext === -1) return $message.error({ message: '公钥加载失败', center: true })
if (ciphertext === -1) return $message.error({ message: t('login.publicKeyLoadFailed'), center: true })
loading.value = true
try {
let { data, msg } = await $api.login({ loginName, ciphertext, jwtExpires, jwtExpireAt, mfa2Token })
const { token, deviceId } = data
$store.setJwtToken(token, expireEnum.ONE_SESSION === expireTime.value)
$store.setUser(loginName, deviceId)
$message.success({ message: msg || 'success', center: true })
$message.success({ message: msg || t('login.messages.success'), center: true })
if (isHttps) return $router.push('/')
if (notHttpsTips) return $router.push('/')
$messageBox.confirm(
'http协议下存在数据劫持风险,外网使用请配置https协议',
'安全提示',
t('login.securityRiskMessage'),
t('login.securityTipTitle'),
{
confirmButtonText: '我知道了',
cancelButtonText: '不再提示',
confirmButtonText: t('login.understood'),
cancelButtonText: t('login.doNotShowAgain'),
type: 'warning',
showCancelButton: true,
cancelButtonClass: 'el-button--info',
+10 -8
View File
@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="visible"
title="安全使用须知"
:title="t('login.warningsTitle')"
width="550px"
:close-on-click-modal="false"
:close-on-press-escape="false"
@@ -10,28 +10,28 @@
<div class="warn-content">
<ol>
<li>
1. 如果你的服务器有<span class="highlight">非常重要的数据</span>,请谨慎在公网暴露此面板。
{{ t('login.warnings.importantData').split('非常重要的数据')[0] }}<span class="highlight">非常重要的数据</span>{{ t('login.warnings.importantData').split('非常重要的数据')[1] }}
</li>
<li>
2. 善用面板提供的<span class="highlight">双重认证</span>与<span class="highlight">IP白名单</span>功能,提升面板安全性。
{{ t('login.warnings.authAndWhitelist').split('双重认证')[0] }}<span class="highlight">双重认证</span>{{ t('login.warnings.authAndWhitelist').split('双重认证')[1].split('IP白名单')[0] }}<span class="highlight">IP白名单</span>{{ t('login.warnings.authAndWhitelist').split('IP白名单')[1] }}
</li>
<li>
3. <span class="highlight">不要安装来源不明的浏览器插件或者油猴脚本</span>,以防止鉴权Token泄露。
3. <span class="highlight">{{ t('login.warnings.noUnknownPlugins').replace('3. ', '').replace(',以防止鉴权Token泄露。', '') }}</span>,以防止鉴权Token泄露。
</li>
<li>
4. 在公网<span class="highlight">不套用https使用此面板</span>可能会导致你的鉴权token泄露。
4. 在公网<span class="highlight">{{ t('login.warnings.useHttps').replace('4. 在公网', '').replace('可能会导致你的鉴权token泄露。', '') }}</span>可能会导致你的鉴权token泄露。
</li>
<li>
5. <span class="highlight">及时更新面板</span>,以防止底层依赖的组件具有安全漏洞或者面板自身服务漏洞。
5. <span class="highlight">{{ t('login.warnings.updatePanel').replace('5. ', '').replace(',以防止底层依赖的组件具有安全漏洞或者面板自身服务漏洞。', '') }}</span>,以防止底层依赖的组件具有安全漏洞或者面板自身服务漏洞。
</li>
<li>
6. 功能更新或安全信息及时通知请订阅TG频道: <span class="point" @click="subTg">点击订阅</span>。
{{ t('login.warnings.subscribeTg') }}<span class="point" @click="subTg">{{ t('login.warnings.subscribeClick') }}</span>。
</li>
</ol>
</div>
<template #footer>
<el-button type="primary" @click="handleConfirm">
我已知晓
{{ t('login.warningAcknowledged') }}
</el-button>
</template>
</el-dialog>
@@ -39,8 +39,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const WARN_STORAGE_KEY = 'easynode_warn_shown'
const { t } = useI18n()
const visible = ref(false)
+38 -36
View File
@@ -7,7 +7,7 @@
:loading="isExecuting"
@click="addOnekey"
>
{{ isExecuting ? `执行中,剩余${timeRemaining}秒` : '批量下发指令' }}
{{ isExecuting ? t('onekey.executingCountdown', { seconds: timeRemaining }) : t('onekey.batchCommand') }}
</el-button>
<!-- <el-button
v-show="recordList.length"
@@ -23,7 +23,7 @@
type="danger"
@click="handleRemoveAll"
>
删除全部记录
{{ t('onekey.deleteAllRecords') }}
</el-button>
</div>
<!-- default-expand-all -->
@@ -42,7 +42,7 @@
</el-table-column>
<el-table-column
prop="name"
label="实例"
:label="t('onekey.instance')"
show-overflow-tooltip
min-width="120px"
>
@@ -54,7 +54,7 @@
</el-table-column>
<el-table-column
prop="command"
label="指令"
:label="t('onekey.command')"
show-overflow-tooltip
min-width="150px"
>
@@ -64,7 +64,7 @@
</el-table-column>
<el-table-column
prop="startDate"
label="开始时间"
:label="t('onekey.startTime')"
show-overflow-tooltip
min-width="150px"
>
@@ -74,7 +74,7 @@
</el-table-column>
<el-table-column
prop="endDate"
label="结束时间"
:label="t('onekey.endTime')"
show-overflow-tooltip
min-width="150px"
>
@@ -84,7 +84,7 @@
</el-table-column>
<el-table-column
prop="status"
label="执行结果"
:label="t('onekey.executionResult')"
show-overflow-tooltip
min-width="100px"
>
@@ -94,7 +94,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="90px">
<el-table-column :label="t('onekey.actions')" fixed="right" width="90px">
<template #default="{ row }">
<el-button
v-if="!row.pending"
@@ -103,7 +103,7 @@
type="danger"
@click="handleRemove([row.id])"
>
删除
{{ t('onekey.delete') }}
</el-button>
</template>
</el-table-column>
@@ -112,7 +112,7 @@
v-model="formVisible"
width="600px"
top="150px"
title="批量下发指令"
:title="t('onekey.batchCommand')"
:close-on-click-modal="false"
@close="clearFormInfo"
>
@@ -125,7 +125,7 @@
label-width="80px"
:show-message="false"
>
<el-form-item label="实例" prop="hostIds">
<el-form-item :label="t('onekey.instance')" prop="hostIds">
<div class="select_host_wrap">
<el-select
v-model="formData.hostIds"
@@ -142,7 +142,7 @@
:indeterminate="indeterminate"
@change="selectAllHost"
>
全选 <span class="tips">(未配置ssh连接信息的实例不会显示在列表中)</span>
{{ t('onekey.selectAll') }} <span class="tips">{{ t('onekey.selectAllHint') }}</span>
</el-checkbox>
</template>
<el-option
@@ -155,7 +155,7 @@
<!-- <el-button type="primary" class="btn" @click="selectAllHost">全选</el-button> -->
</div>
</el-form-item>
<el-form-item prop="command" label="指令">
<el-form-item prop="command" :label="t('onekey.command')">
<div class="command_wrap">
<el-dropdown
trigger="click"
@@ -163,7 +163,7 @@
:teleported="false"
class="scripts_menu"
>
<span class="link_text">从脚本库导入...<el-icon><arrow-down /></el-icon></span>
<span class="link_text">{{ t('onekey.importFromScriptLibrary') }}<el-icon><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in scriptList" :key="item.id" @click="handleImportScript(item)">
@@ -179,24 +179,24 @@
:rows="5"
clearable
autocomplete="off"
placeholder="shell script, ex: ping -c 10 google.com"
:placeholder="t('onekey.shellPlaceholder')"
/>
</div>
</el-form-item>
<el-form-item prop="timeout" label="超时(s)">
<el-form-item prop="timeout" :label="t('onekey.timeoutSeconds')">
<el-input
v-model.trim.number="formData.timeout"
type="number"
clearable
autocomplete="off"
placeholder="指令执行超时时间,单位秒,超时自动中断"
:placeholder="t('onekey.timeoutPlaceholder')"
/>
</el-form-item>
</el-form>
<template #footer>
<span>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="execOnekey">执行</el-button>
<el-button @click="formVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="execOnekey">{{ t('terminal.execute') }}</el-button>
</span>
</template>
</el-dialog>
@@ -205,10 +205,12 @@
<script setup>
import { ref, reactive, onMounted, computed, watch, nextTick, getCurrentInstance, onActivated } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown } from '@element-plus/icons-vue'
import { generateSocketInstance } from '@/utils'
const { proxy: { $api, $notification,$messageBox, $message, $router, $store, $tools } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const formVisible = ref(false)
@@ -297,7 +299,7 @@ const createExecShell = (hostIds = [], command = 'ls', timeout = 60) => {
socket.value.on('exec_timeout', ({ reason, result }) => {
$notification({
title: '批量指令执行超时',
title: t('onekey.executionTimeoutTitle'),
message: reason,
type: 'error'
})
@@ -306,8 +308,8 @@ const createExecShell = (hostIds = [], command = 'ls', timeout = 60) => {
socket.value.on('exec_complete', () => {
$notification({
title: '批量指令执行完成',
message: '执行完成',
title: t('onekey.executionCompleteTitle'),
message: t('onekey.executionComplete'),
type: 'success'
})
getOnekeyRecord()
@@ -327,8 +329,8 @@ const createExecShell = (hostIds = [], command = 'ls', timeout = 60) => {
loading.value = false
console.error('onekey websocket 连接错误:', err)
$notification({
title: 'onekey websocket 连接错误:',
message: '请检查socket服务是否正常',
title: t('onekey.websocketConnectErrorTitle'),
message: t('onekey.websocketServiceError'),
type: 'error'
})
})
@@ -354,19 +356,19 @@ let handleImportScript = (scriptObj) => {
let getStatusType = (status) => {
switch (status) {
case '连接中':
case t('onekey.status.connecting'):
return '#FFDEAD'
case '连接失败':
case t('onekey.status.connectFailed'):
return '#FFCCCC'
case '执行中':
case t('onekey.status.executing'):
return '#ADD8E6'
case '执行成功':
case t('onekey.status.success'):
return '#90EE90'
case '执行失败':
case t('onekey.status.failed'):
return '#FFCCCC'
case '执行超时':
case t('onekey.status.timeout'):
return '#FFFFE0'
case '执行中断':
case t('onekey.status.interrupted'):
return '#E6E6FA'
default:
return 'info'
@@ -391,10 +393,10 @@ function execOnekey() {
let { hostIds, command, timeout } = formData
timeout = Number(timeout)
if (timeout < 1) {
return $message.error('超时时间不能小于1秒')
return $message.error(t('onekey.timeoutMinError'))
}
if (hostIds.length === 0) {
return $message.error('请选择主机')
return $message.error(t('onekey.selectHostRequired'))
}
await getOnekeyRecord() // 获取新纪录前会清空 pendingRecord,所以需要获取一次最新的list
createExecShell(hostIds, command, timeout)
@@ -414,9 +416,9 @@ const handleRemove = async (ids = []) => {
}
const handleRemoveAll = async () => {
$messageBox.confirm(`确认删除所有执行记录:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('onekey.deleteAllConfirm'), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
+43 -41
View File
@@ -41,37 +41,37 @@
class="display_mode_select"
size="small"
:disabled="isConnecting"
placeholder="选择显示模式"
:placeholder="t('rdp.displayModePlaceholder')"
@change="handleDisplayModeChange"
>
<el-option label="最大化" value="maximize" />
<el-option label="自适应" value="adaptive" />
<el-option label="自定义" value="custom" @click="handleCustomOptionClick" />
<el-option :label="t('rdp.displayModeMaximize')" value="maximize" />
<el-option :label="t('rdp.displayModeAdaptive')" value="adaptive" />
<el-option :label="t('rdp.displayModeCustom')" value="custom" @click="handleCustomOptionClick" />
</el-select>
<el-select
v-model="qualityLevel"
class="quality_select"
size="small"
:disabled="isConnecting"
placeholder="清晰度"
:placeholder="t('rdp.qualityPlaceholder')"
@change="handleQualityChange"
>
<el-option label="流畅" value="low">
<el-option :label="t('rdp.qualityLow')" value="low">
<span style="display: flex; align-items: center; gap: 4px;">
<span>流畅</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">省流</span>
<span>{{ t('rdp.qualityLow') }}</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">{{ t('rdp.qualityLowHint') }}</span>
</span>
</el-option>
<el-option label="清晰" value="medium">
<el-option :label="t('rdp.qualityMedium')" value="medium">
<span style="display: flex; align-items: center; gap: 4px;">
<span>清晰</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">均衡</span>
<span>{{ t('rdp.qualityMedium') }}</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">{{ t('rdp.qualityMediumHint') }}</span>
</span>
</el-option>
<el-option label="高清" value="high">
<el-option :label="t('rdp.qualityHigh')" value="high">
<span style="display: flex; align-items: center; gap: 4px;">
<span>高清</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">高带宽</span>
<span>{{ t('rdp.qualityHigh') }}</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px;">{{ t('rdp.qualityHighHint') }}</span>
</span>
</el-option>
</el-select>
@@ -86,17 +86,17 @@
:loading="isConnecting"
@click="connectRdp"
>
{{ isConnecting ? '连接中' : '重连' }}
{{ isConnecting ? t('rdp.connecting') : t('rdp.reconnect') }}
</el-button>
<el-button type="info" size="small" @click="moveToBackground"> 挂起 </el-button>
<el-button type="danger" size="small" @click="() => $emit('disconnect')"> 断开 </el-button>
<el-button type="info" size="small" @click="moveToBackground">{{ t('rdp.suspend') }}</el-button>
<el-button type="danger" size="small" @click="() => $emit('disconnect')">{{ t('rdp.disconnect') }}</el-button>
<el-button
v-show="isMobile()"
type="success"
size="small"
@click="toggleKeyboard"
>
键盘
{{ t('rdp.keyboard') }}
</el-button>
</div>
</div>
@@ -104,7 +104,7 @@
<div
ref="rdpContainer"
v-loading="isConnecting"
element-loading-text="连接中..."
:element-loading-text="t('rdp.connectingWithEllipsis')"
class="rdp_container"
/>
</el-dialog>
@@ -112,27 +112,27 @@
<!-- 自定义分辨率设置对话框 -->
<el-dialog
v-model="customDialogVisible"
title="自定义分辨率"
:title="t('rdp.customResolution')"
width="400px"
:close-on-click-modal="false"
>
<el-form label-width="60px">
<el-form-item label="宽度">
<el-form-item :label="t('rdp.width')">
<el-input
v-model="tempCustomWidth"
type="number"
:max="maxWidth"
:placeholder="`最大${maxWidth}`"
:placeholder="t('rdp.maxResolutionValue', { value: maxWidth })"
>
<template #append>px</template>
</el-input>
</el-form-item>
<el-form-item label="高度">
<el-form-item :label="t('rdp.height')">
<el-input
v-model="tempCustomHeight"
type="number"
:max="maxHeight"
:placeholder="`最大${maxHeight}`"
:placeholder="t('rdp.maxResolutionValue', { value: maxHeight })"
>
<template #append>px</template>
</el-input>
@@ -140,7 +140,7 @@
<el-alert type="info" :closable="false">
<template #default>
<div style="display: flex; align-items: center; gap: 8px;">
<span>最大可用分辨率:</span>
<span>{{ t('rdp.maxAvailableResolution') }}</span>
<el-link
type="primary"
:underline="false"
@@ -154,14 +154,15 @@
</el-alert>
</el-form>
<template #footer>
<el-button @click="cancelCustomSettings">取消</el-button>
<el-button type="primary" @click="confirmCustomSettings">确定</el-button>
<el-button @click="cancelCustomSettings">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="confirmCustomSettings">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { Connection, Loading, Close, Clock } from '@element-plus/icons-vue'
import Guacamole from 'guacamole-common-js'
@@ -169,6 +170,7 @@ import { rdpStatus, rdpStatusList } from '@/utils/enum'
import { isMobile } from '@/utils'
const { proxy: { $api, $message, $isDev } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
host: {
type: Object,
@@ -428,7 +430,7 @@ const moveToBackground = () => {
const getRdpWsUrl = async () => {
const { width, height, quality, dpi } = appliedConfig.value
const { data } = await $api.getRdpToken({ hostId: hostId.value, width, height, quality, dpi })
if (!data) return $message.error('获取RDP WS URL失败')
if (!data) return $message.error(t('rdp.getWsUrlFailed'))
const wsHost = $isDev ? `ws://${ location.hostname }:8082` : location.origin.replace('http', 'ws')
return `${ wsHost }/rdp-proxy/guac?token=${ encodeURIComponent(data) }`
}
@@ -465,7 +467,7 @@ const connectRdp = async () => {
if (isConnecting.value) {
console.warn('⏰ RDP连接超时')
currentStatus.value = rdpStatus.TIMEOUT
$message.error('RDP连接超时,请检查网络或目标主机状态')
$message.error(t('rdp.connectTimeout'))
disconnectRdp()
}
}, 30000)
@@ -477,7 +479,7 @@ const connectRdp = async () => {
tunnel.value.onerror = (error) => {
console.error('🚇 WebSocket 隧道错误:', error)
currentStatus.value = rdpStatus.ERROR
$message.error('WebSocket连接错误')
$message.error(t('rdp.websocketError'))
clearConnectionTimeout()
}
@@ -496,7 +498,7 @@ const connectRdp = async () => {
} catch (error) {
console.error('RDP连接失败:', error)
currentStatus.value = rdpStatus.ERROR
$message.error('RDP连接失败: ' + error.message)
$message.error(t('rdp.connectFailed', { message: error.message }))
clearConnectionTimeout()
}
}
@@ -525,17 +527,17 @@ function mapGuacError(status) {
msg.includes('authentication failure'))
) {
ElMessageBox.confirm(
`用户名(${ username.value })长度小于8位,会有连接失败的可能,请使用Administrator或设置用户名大于等于8位。`,
'连接失败',
t('rdp.shortUsernameHint', { username: username.value }),
t('rdp.connectFailedTitle'),
{
confirmButtonText: '确定',
confirmButtonText: t('common.confirm'),
showCancelButton: false,
type: 'error'
}
)
}
return `连接失败:${ msg }`
return t('rdp.connectFailedMessage', { message: msg })
}
const setupEventHandlers = () => {
@@ -565,7 +567,7 @@ const setupEventHandlers = () => {
onDisconnected()
break
default:
console.warn('未知RDP连接状态:', state)
console.warn('Unknown RDP connection state:', state)
currentStatus.value = rdpStatus.ERROR
break
}
@@ -788,7 +790,7 @@ const onConnected = () => {
if (!text) return
sendClipboardToRemote(text)
} catch (err) {
console.debug('无法读取本地剪贴板:', err.message)
console.debug('Unable to read local clipboard:', err.message)
}
})
displayElement.addEventListener('blur', () => { if (keyboard) keyboard.reset() })
@@ -800,7 +802,7 @@ const onConnected = () => {
// 处理显示尺寸变化
const display = client.value.getDisplay()
display.onresize = (width, height) => {
console.log('RDP显示尺寸变化:', width, 'x', height)
console.log('RDP display size changed:', width, 'x', height)
// 确保显示缩放始终为1:1
const scale = appliedConfig.value.scale || 1
display.scale(scale)
@@ -815,7 +817,7 @@ const onConnected = () => {
}
const onDisconnected = () => {
console.log('RDP连接已断开')
console.log('RDP disconnected')
clearConnectionTimeout()
cleanupResources()
disableMobileKeyboard()
@@ -826,7 +828,7 @@ const disconnectRdp = () => {
try {
client.value.disconnect()
} catch (error) {
console.warn('断开RDP连接时出错:', error)
console.warn('Error while disconnecting RDP:', error)
}
}
currentStatus.value = rdpStatus.DISCONNECTED
@@ -865,7 +867,7 @@ const cleanupResources = () => {
try {
tunnel.value.disconnect()
} catch (error) {
console.warn('关闭WebSocket隧道时出错:', error)
console.warn('Error while closing WebSocket tunnel:', error)
}
tunnel.value = null
}
+9 -7
View File
@@ -3,7 +3,7 @@
<div class="header">
<el-dropdown trigger="click">
<el-button type="primary">
新建连接<el-icon class="el-icon--right"><ArrowDown /></el-icon>
{{ t('rdp.newConnection') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
@@ -15,7 +15,7 @@
</el-dropdown>
</div>
<div v-if="noRDPHost" class="no_rdp_host">
<el-empty description="无远程桌面连接" />
<el-empty :description="t('rdp.empty')" />
</div>
<div v-else class="rdp_host_list">
<ul>
@@ -49,6 +49,7 @@
<script setup>
import { onActivated, computed, nextTick, getCurrentInstance, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { Monitor, Close, ArrowDown } from '@element-plus/icons-vue'
import { rdpStatus, rdpStatusList } from '@/utils/enum'
@@ -57,6 +58,7 @@ import HostForm from '../server/components/host-form.vue'
const route = useRoute()
const { proxy: { $store, $message } } = getCurrentInstance()
const { t } = useI18n()
// RDP组件引用
const rdpRefs = ref({})
@@ -76,7 +78,7 @@ const getStatusColor = (status) => {
}
const getStatusLabel = (status) => {
return (
rdpStatusList.find((item) => item.value === status)?.label || '未知状态'
rdpStatusList.find((item) => item.value === status)?.label || t('rdp.unknownStatus')
)
}
@@ -84,7 +86,7 @@ onActivated(async () => {
await nextTick()
const { hostIds } = route.query
if (!hostIds) return
if (rdpTabs.some(item => hostIds.includes(item.id))) return $message.warning('已存在该实例的RDP连接')
if (rdpTabs.some(item => hostIds.includes(item.id))) return $message.warning(t('rdp.existingConnection'))
let targetHosts = hostList.value.filter(item => hostIds.includes(item.id)).map(item => {
const { id, name, host, username } = item
return { show: true, status: rdpStatus.IDLE, id, name, host, username }
@@ -137,8 +139,8 @@ const handleRemoveRdpTab = (item) => {
const addRDP = (item) => {
const { id, name, host, username, isConfig } = item
if (!isConfig) return $message.warning('请先配置RDP连接信息')
if (rdpTabs.some(tab => tab.id === id)) return $message.warning('已存在该实例的RDP连接')
if (!isConfig) return $message.warning(t('rdp.configRequired'))
if (rdpTabs.some(tab => tab.id === id)) return $message.warning(t('rdp.existingConnection'))
rdpTabs.push({ show: true, status: rdpStatus.IDLE, id, name, host, username })
}
@@ -148,7 +150,7 @@ const handleUpdateList = async ({ host }) => {
let targetHost = hostList.value.find((item) => item.host === host)
if (targetHost) addRDP(targetHost)
} catch (err) {
$message.error('获取实例列表失败')
$message.error(t('common.fetchServerListFailed'))
console.error('获取实例列表失败: ', err)
}
}
@@ -5,11 +5,11 @@
top="225px"
modal-class="import_script_dialog"
append-to-body
title="导入脚本配置"
:title="t('scripts.importConfigTitle')"
:close-on-click-modal="false"
>
<h2>1. 选择要导入的分组</h2>
<el-select v-model="targetGroup" placeholder="请选择分组" style="width: 50%;margin-bottom: 10px;">
<h2>{{ t('scripts.chooseImportGroup') }}</h2>
<el-select v-model="targetGroup" :placeholder="t('scripts.selectGroup')" style="width: 50%;margin-bottom: 10px;">
<el-option
v-for="item in groupList"
:key="item.id"
@@ -18,7 +18,7 @@
/>
</el-select>
<h2>2. 选择要导入的文件类型</h2>
<h2>{{ t('scripts.chooseImportType') }}</h2>
<ul class="type_list">
<li @click="handleFromJson">
<svg-icon name="icon-json" class="icon" />
@@ -35,7 +35,7 @@
</li>
<li @click="() => manualInputVisible = true">
<svg-icon name="icon-bianji1" class="icon" />
<span class="from">手动输入</span>
<span class="from">{{ t('scripts.manualInput') }}</span>
</li>
</ul>
</el-dialog>
@@ -44,7 +44,7 @@
v-model="manualInputVisible"
width="600px"
top="150px"
title="手动输入"
:title="t('scripts.manualInputTitle')"
:close-on-click-modal="false"
append-to-body
>
@@ -52,12 +52,12 @@
v-model="manualInput"
type="textarea"
:autosize="{ minRows: 15 }"
placeholder="请输入脚本内容,每行一条脚本"
:placeholder="t('scripts.manualInputPlaceholder')"
/>
<template #footer>
<div class="manual-input-footer">
<el-button @click="manualInputVisible = false">取消</el-button>
<el-button type="primary" @click="handleManualImport">导入</el-button>
<el-button @click="manualInputVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleManualImport">{{ t('scripts.import') }}</el-button>
</div>
</template>
</el-dialog>
@@ -65,8 +65,10 @@
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -97,7 +99,7 @@ function handleFromJson() {
const handleJsonFile = (event) => {
let files = event.target.files
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
if (jsonFiles.length === 0) return $message.warning(t('scripts.invalidJsonFile'))
let readerPromises = jsonFiles.map(file => {
return new Promise((resolve, reject) => {
@@ -125,7 +127,7 @@ const handleJsonFile = (event) => {
formatJson = formatJson.filter(({ _id, command }) => {
return !existCommand.includes(command) && !existId.includes(_id)
})
if (formatJson.length === 0) return $message.warning('导入的脚本已存在')
if (formatJson.length === 0) return $message.warning(t('scripts.importedScriptsExist'))
formatJson = formatJson.map((item) => {
return {
...item,
@@ -134,15 +136,15 @@ const handleJsonFile = (event) => {
})
try {
let { data: { len } } = await $api.importScript({ scripts: formatJson })
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
$message({ type: 'success', center: true, message: t('scripts.importSuccess', { count: len }) })
emit('update-list')
visible.value = false
} catch (error) {
$message.error('导入失败: ' + error.message)
$message.error(t('scripts.importFailed', { message: error.message }))
}
})
.catch(error => {
$message.error('导入失败: ' + error.message)
$message.error(t('scripts.importFailed', { message: error.message }))
console.error('导入失败: ', error)
})
.finally(() => {
@@ -152,7 +154,7 @@ const handleJsonFile = (event) => {
const handleManualImport = async () => {
if (!manualInput.value.trim()) {
return $message.warning('请输入脚本内容')
return $message.warning(t('scripts.inputScriptContent'))
}
try {
@@ -161,7 +163,7 @@ const handleManualImport = async () => {
.filter(line => line.trim())
.map((command) => ({ command: command.trim() }))
if (scripts.length === 0) {
return $message.warning('未检测到有效的脚本内容')
return $message.warning(t('scripts.noValidScriptContent'))
}
let existCommand = scriptList.value.map(item => item.command)
@@ -169,26 +171,26 @@ const handleManualImport = async () => {
return !existCommand.includes(command)
})
let filterScriptsLen = filterScripts.length
if (filterScriptsLen !== 0 && filterScriptsLen < scripts.length) $message.warning('已过滤重复的脚本')
if (filterScriptsLen === 0) return $message.warning('导入的脚本已存在')
if (filterScriptsLen !== 0 && filterScriptsLen < scripts.length) $message.warning(t('scripts.duplicateScriptsFiltered'))
if (filterScriptsLen === 0) return $message.warning(t('scripts.importedScriptsExist'))
filterScripts = filterScripts.map((item, index) => {
return {
...item,
name: `${ item.command.slice(0, 15) || `脚本${ index + 1 }` }`,
name: `${ item.command.slice(0, 15) || t('scripts.scriptDefaultName', { index: index + 1 }) }`,
index: scriptList.value.length + index + 1,
description: '手动输入',
description: t('scripts.manualInputDescription'),
group: targetGroup.value
}
})
let { data: { len } } = await $api.importScript({ scripts: filterScripts })
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
$message({ type: 'success', center: true, message: t('scripts.importSuccess', { count: len }) })
emit('update-list')
manualInputVisible.value = false
visible.value = false
manualInput.value = ''
} catch (error) {
$message.error('导入失败: ' + error.message)
$message.error(t('scripts.importFailed', { message: error.message }))
}
}
</script>
@@ -3,7 +3,7 @@
v-model="visible"
width="600px"
top="150px"
:title="isModify ? '修改脚本' : '添加脚本'"
:title="isModify ? t('scripts.editScriptTitle') : t('scripts.addScriptTitle')"
:close-on-click-modal="false"
@open="handleOpen"
@close="handleClose"
@@ -17,7 +17,7 @@
label-width="100px"
:show-message="false"
>
<el-form-item key="group" label="分组" prop="group">
<el-form-item key="group" :label="t('scripts.group')" prop="group">
<el-select
v-model="formData.group"
placeholder=""
@@ -33,7 +33,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-form-item :label="t('scripts.name')" prop="name">
<el-input
v-model="formData.name"
clearable
@@ -41,7 +41,7 @@
autocomplete="off"
/>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-form-item :label="t('scripts.description')" prop="description">
<el-input
v-model="formData.description"
clearable
@@ -49,7 +49,7 @@
autocomplete="off"
/>
</el-form-item>
<el-form-item label="序号" prop="index">
<el-form-item :label="t('scripts.order')" prop="index">
<el-input
v-model.trim.number="formData.index"
clearable
@@ -57,7 +57,7 @@
autocomplete="off"
/>
</el-form-item>
<el-form-item prop="command" label="内容">
<el-form-item prop="command" :label="t('scripts.content')">
<el-input
v-model="formData.command"
type="textarea"
@@ -65,35 +65,35 @@
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="shell script"
:placeholder="t('scripts.shellScriptPlaceholder')"
/>
</el-form-item>
<el-form-item label="编码方式" prop="useBase64">
<el-form-item :label="t('scripts.encodingMode')" prop="useBase64">
<el-radio-group v-model="formData.useBase64">
<el-radio :value="false">
<span>直接发送</span>
<span>{{ t('scripts.directSend') }}</span>
<el-tooltip placement="right">
<template #content>
<div style="max-width: 300px;">
适用于单行简单脚本。<br>
脚本内容会直接发送到终端。<br>
注意:多行脚本会逐行自动执行。
{{ t('scripts.directSendTip1') }}<br>
{{ t('scripts.directSendTip2') }}<br>
{{ t('scripts.directSendTip3') }}
</div>
</template>
<el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
<el-radio :value="true">
<span>Base64编码</span>
<span>{{ t('scripts.base64Encoding') }}</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
{{ t('scripts.base64Tip1') }}<br>
{{ t('scripts.base64Tip2') }}<br>
{{ t('scripts.base64TipEscape') }}<br>
{{ t('scripts.base64TipHeredoc') }}<br>
{{ t('scripts.base64TipNewline') }}<br>
{{ t('scripts.commandFormat') }}
</div>
</template>
<el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
@@ -104,8 +104,8 @@
</el-form>
<template #footer>
<span>
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="handleSubmit">{{ isModify ? '修改' : '添加' }}</el-button>
<el-button @click="visible = false">{{ t('scripts.close') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ isModify ? t('scripts.edit') : t('scripts.add') }}</el-button>
</span>
</template>
</el-dialog>
@@ -114,6 +114,7 @@
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { QuestionFilled } from '@element-plus/icons-vue'
const props = defineProps({
@@ -138,6 +139,7 @@ const props = defineProps({
const emit = defineEmits(['update:show', 'success',])
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const { t } = useI18n()
const visible = computed({
get: () => props.show,
@@ -161,13 +163,13 @@ const formData = reactive({
})
const isModify = computed(() => Boolean(formData.id))
const rules = {
group: { required: true, message: '选择一个分组' },
const rules = computed(() => ({
group: { required: true, message: t('scripts.selectGroupRequired') },
name: { required: true, trigger: 'change' },
description: { required: false, trigger: 'change' },
index: { required: false, type: 'number', trigger: 'change' },
command: { required: true, trigger: 'change' }
}
}))
watch(() => props.defaultData, (newVal) => {
if (newVal?.id) {
@@ -4,17 +4,17 @@
width="600px"
:top="isMobile() ? '45px' : '15vh'"
:append-to-body="false"
title="脚本分组管理"
:title="t('scripts.scriptGroupManagement')"
:close-on-click-modal="false"
>
<div class="group_container">
<div class="header">
<el-button type="primary" @click="addGroup">添加分组</el-button>
<el-button type="primary" @click="addGroup">{{ t('scripts.addGroup') }}</el-button>
</div>
<el-table v-loading="loading" :data="list">
<el-table-column prop="index" label="序号" />
<el-table-column prop="name" label="分组名称" />
<el-table-column label="关联脚本数量" min-width="115px">
<el-table-column prop="index" :label="t('scripts.index')" />
<el-table-column prop="name" :label="t('scripts.groupName')" />
<el-table-column :label="t('scripts.relatedScriptCount')" min-width="115px">
<template #default="{ row }">
<el-popover
v-if="row.scripts.list.length !== 0"
@@ -36,20 +36,20 @@
<u v-else class="script_count">0</u>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="160px">
<el-table-column :label="t('scripts.actions')" fixed="right" width="160px">
<template #default="{ row }">
<template v-if="row.id !== 'builtin'">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button type="primary" @click="handleChange(row)">{{ t('scripts.edit') }}</el-button>
<el-button
v-show="row.id !== 'default'"
type="danger"
@click="deleteGroup(row)"
>
删除
{{ t('scripts.delete') }}
</el-button>
</template>
<template v-else>
<span>--</span>
<span>{{ t('scripts.noAction') }}</span>
</template>
</template>
</el-table-column>
@@ -59,7 +59,7 @@
v-model="groupFormVisible"
width="600px"
top="150px"
:title="isModify ? '修改分组' : '添加分组'"
:title="isModify ? t('scripts.editGroupTitle') : t('scripts.addGroupTitle')"
:close-on-click-modal="false"
@close="clearFormInfo"
>
@@ -69,17 +69,17 @@
:rules="rules"
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-form-item :label="t('scripts.groupName')" prop="name">
<el-input v-model="groupForm.name" />
</el-form-item>
<el-form-item label="序号" prop="index">
<el-form-item :label="t('scripts.index')" prop="index">
<el-input v-model.number="groupForm.index" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="groupFormVisible = false">取消</el-button>
<el-button type="primary" @click="updateForm">确定</el-button>
<el-button @click="groupFormVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="updateForm">{{ t('common.confirm') }}</el-button>
</span>
</template>
</el-dialog>
@@ -89,6 +89,7 @@
<script setup>
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { isMobile } from '@/utils'
const props = defineProps({
@@ -106,6 +107,7 @@ const visible = computed({
})
const { proxy: { $api, $store, $message, $messageBox } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const groupFormVisible = ref(false)
@@ -118,10 +120,10 @@ const groupForm = ref({
index: 0
})
const rules = {
name: [{ required: true, message: '请输入分组名称', trigger: 'blur' },],
index: [{ type: 'number', message: '序号必须为数字值' },]
}
const rules = computed(() => ({
name: [{ required: true, message: t('scripts.groupNameRequired'), trigger: 'blur' },],
index: [{ type: 'number', message: t('scripts.indexMustBeNumber') },]
}))
const scriptList = computed(() => $store.scriptList)
const groupList = computed(() => $store.scriptGroupList)
@@ -173,9 +175,9 @@ const clearFormInfo = () => {
}
const deleteGroup = ({ id, name }) => {
$messageBox.confirm(`确认删除分组:${ name } (分组下脚本将移动至默认分组)`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('scripts.deleteGroupConfirm', { name }), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
+28 -26
View File
@@ -3,7 +3,7 @@
<div class="header">
<el-input
v-model="searchKeyword"
placeholder="搜索名称、描述或指令内容"
:placeholder="t('scripts.searchPlaceholder')"
class="search_input"
clearable
@input="handleSearch"
@@ -17,18 +17,18 @@
type="danger"
@click="handleBatchRemove"
>
批量删除
{{ t('scripts.batchDelete') }}
</el-button>
<el-button type="primary" @click="addScript">添加脚本</el-button>
<el-button type="primary" @click="addScript">{{ t('scripts.addScript') }}</el-button>
<PlusSupportTip>
<el-dropdown trigger="click" :disabled="!isPlusActive">
<el-button type="primary" class="group_action_btn" :disabled="!isPlusActive">
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
{{ t('scripts.importExport') }}<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="importVisible = true">导入脚本</el-dropdown-item>
<el-dropdown-item @click="handleExport">导出脚本</el-dropdown-item>
<el-dropdown-item @click="importVisible = true">{{ t('scripts.importScript') }}</el-dropdown-item>
<el-dropdown-item @click="handleExport">{{ t('scripts.exportScript') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -40,7 +40,7 @@
:disabled="!isPlusActive"
@click="ScriptGroupVisible = true"
>
分组管理
{{ t('scripts.groupManagement') }}
</el-button>
</PlusSupportTip>
</div>
@@ -64,23 +64,23 @@
return row.index !== '--' && row.index !== '-' && row.index !== undefined && row.index !== null
}"
/>
<el-table-column prop="index" label="序号" width="100px" />
<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="index" :label="t('scripts.index')" width="100px" />
<el-table-column prop="name" :label="t('scripts.name')" />
<el-table-column prop="description" :label="t('scripts.description')" />
<el-table-column prop="command" :label="t('scripts.commandContent')" show-overflow-tooltip />
<el-table-column
prop="useBase64"
label="编码方式"
:label="t('scripts.encodingMode')"
width="140px"
:formatter="formatExecutionMode"
/>
<el-table-column label="操作" fixed="right" width="160px">
<el-table-column :label="t('scripts.actions')" fixed="right" width="160px">
<template #default="{ row }">
<template v-if="row.index !== '--'">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'own'" type="danger" @click="handleRemove(row)">删除</el-button>
<el-button type="primary" @click="handleChange(row)">{{ t('scripts.edit') }}</el-button>
<el-button v-show="row.id !== 'own'" type="danger" @click="handleRemove(row)">{{ t('scripts.delete') }}</el-button>
</template>
<span v-else>--</span>
<span v-else>{{ t('scripts.noAction') }}</span>
</template>
</el-table-column>
</el-table>
@@ -120,6 +120,7 @@
<script setup>
import { ref, reactive, computed, nextTick, getCurrentInstance, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ImportScript from './components/import-script.vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
import { ArrowDown, Search } from '@element-plus/icons-vue'
@@ -128,6 +129,7 @@ import ScriptGroup from './components/script-group.vue'
import ScriptEdit from './components/script-edit.vue'
const { proxy: { $api, $message, $messageBox, $store, $tools } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const formVisible = ref(false)
@@ -136,12 +138,12 @@ const handleSelectionChange = (val) => {
selectScripts.value = val
}
const handleBatchRemove = () => {
if (!selectScripts.value.length) return $message.warning('请选择要批量删除的脚本')
if (!selectScripts.value.length) return $message.warning(t('scripts.selectScriptsFirst'))
let ids = selectScripts.value.map(item => item.id)
let names = selectScripts.value.map(item => item.name)
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, t('scripts.deleteBatchConfirm', { names: names.join(', ') })), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(async () => {
await $api.batchRemoveScript({ ids })
@@ -168,9 +170,9 @@ const handleChange = (row) => {
}
const handleRemove = ({ id, name }) => {
$messageBox.confirm(`确认删除该脚本:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('scripts.deleteConfirm', { name }), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -183,7 +185,7 @@ const handleRemove = ({ id, name }) => {
const importVisible = ref(false)
const handleExport = () => {
if (!scriptList.value.length) return $message.warning('暂无可导出的脚本')
if (!scriptList.value.length) return $message.warning(t('scripts.noExportableScripts'))
const fileName = `easynode-scripts-${ $tools.formatTimestamp(Date.now(), 'time', '.') }.json`
exportFile(scriptList.value, fileName, 'application/json')
}
@@ -247,9 +249,9 @@ const handleEditSuccess = () => {
// 格式化编码方式显示
const formatExecutionMode = (row, column, cellValue) => {
if (cellValue === true) {
return 'Base64编码'
return t('scripts.base64Encoding')
}
return '直接发送'
return t('scripts.sendDirectly')
}
</script>
+21 -19
View File
@@ -4,17 +4,17 @@
width="600px"
:top="isMobile() ? '45px' : '15vh'"
:append-to-body="false"
title="实例分组管理"
:title="t('server.group.title')"
:close-on-click-modal="false"
>
<div class="group_container">
<div class="header">
<el-button type="primary" @click="addGroup">添加分组</el-button>
<el-button type="primary" @click="addGroup">{{ t('server.group.addGroup') }}</el-button>
</div>
<el-table v-loading="loading" :data="list">
<el-table-column prop="index" label="序号" />
<el-table-column prop="name" label="分组名称" />
<el-table-column label="关联实例数量" min-width="115px">
<el-table-column prop="index" :label="t('server.index')" />
<el-table-column prop="name" :label="t('server.group.groupName')" />
<el-table-column :label="t('server.group.relatedInstanceCount')" min-width="115px">
<template #default="{ row }">
<el-popover
v-if="row.hosts.list.length !== 0"
@@ -36,10 +36,10 @@
<u v-else class="host_count">0</u>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="160px">
<el-table-column :label="t('server.group.actions')" fixed="right" width="160px">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
<el-button type="primary" @click="handleChange(row)">{{ t('server.group.edit') }}</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">{{ t('server.group.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -47,7 +47,7 @@
v-model="groupFormVisible"
width="600px"
top="150px"
:title="isModify ? '修改分组' : '添加分组'"
:title="isModify ? t('server.group.editTitle') : t('server.group.addTitle')"
:close-on-click-modal="false"
@close="clearFormInfo"
>
@@ -60,7 +60,7 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="分组名称" prop="name">
<el-form-item :label="t('server.group.groupName')" prop="name">
<el-input
v-model="groupForm.name"
clearable
@@ -69,19 +69,19 @@
autocomplete="off"
/>
</el-form-item>
<el-form-item label="分组序号" prop="index">
<el-form-item :label="t('server.group.groupOrder')" prop="index">
<el-input
v-model.number="groupForm.index"
clearable
placeholder="用于分组排序"
:placeholder="t('server.group.orderPlaceholder')"
autocomplete="off"
/>
</el-form-item>
</el-form>
<template #footer>
<span>
<el-button @click="groupFormVisible = false">关闭</el-button>
<el-button type="primary" @click="updateForm">{{ isModify ? '修改' : '添加' }}</el-button>
<el-button @click="groupFormVisible = false">{{ t('server.group.close') }}</el-button>
<el-button type="primary" @click="updateForm">{{ isModify ? t('server.group.edit') : t('server.group.add') }}</el-button>
</span>
</template>
</el-dialog>
@@ -92,8 +92,10 @@
<script setup>
import { isMobile } from '@/utils'
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -120,8 +122,8 @@ const groupForm = reactive({
const rules = computed(() => {
return {
name: { required: true, message: '需输入分组名称', trigger: 'change' },
index: { required: true, type: 'number', message: '需输入数字', trigger: 'change' }
name: { required: true, message: t('server.group.nameRequired'), trigger: 'change' },
index: { required: true, type: 'number', message: t('server.group.numberRequired'), trigger: 'change' }
}
})
@@ -177,9 +179,9 @@ const clearFormInfo = () => {
}
const deleteGroup = ({ id, name }) => {
$messageBox.confirm(`确认删除分组:${ name } (分组下实例将移动至默认分组)`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('server.group.deleteConfirm', { name }), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
+57 -55
View File
@@ -11,7 +11,7 @@
@closed="handleClosed"
>
<div v-if="isBatchModify" class="batch_info">
<el-alert title="正在进行批量修改操作,留空默认保留原值" type="warning" :closable="false" />
<el-alert :title="t('server.form.batchModifyHint')" type="warning" :closable="false" />
<el-tag
v-for="item in batchHosts"
:key="item.id"
@@ -30,7 +30,7 @@
label-width="100px"
:show-message="false"
>
<el-form-item key="connectType" label="连接类型" prop="connectType">
<el-form-item key="connectType" :label="t('server.form.connectType')" prop="connectType">
<el-radio-group v-model="hostForm.connectType">
<el-radio value="ssh">SSH <svg-icon name="icon-linux" class="icon" /></el-radio>
<PlusSupportTip>
@@ -38,7 +38,7 @@
</PlusSupportTip>
</el-radio-group>
</el-form-item>
<el-form-item key="group" label="分组" prop="group">
<el-form-item key="group" :label="t('server.form.group')" prop="group">
<el-select
v-model="hostForm.group"
placeholder=""
@@ -58,7 +58,7 @@
v-if="!isBatchModify"
key="name"
class="form_item_left"
label="名称"
:label="t('server.form.name')"
prop="name"
>
<el-input
@@ -72,13 +72,13 @@
v-if="!isBatchModify"
key="index"
class="form_item_right"
label="序号"
:label="t('server.form.index')"
prop="index"
>
<el-input
v-model.trim.number="hostForm.index"
clearable
placeholder="用于实例列表中排序(填写数字)"
:placeholder="t('server.form.indexPlaceholder')"
autocomplete="off"
/>
</el-form-item>
@@ -88,20 +88,20 @@
v-if="!isBatchModify"
key="host"
class="form_item_left"
label="主机"
:label="t('server.form.host')"
prop="host"
>
<el-input
v-model.trim="hostForm.host"
clearable
placeholder="IP"
:placeholder="t('server.form.ipPlaceholder')"
autocomplete="off"
/>
</el-form-item>
<el-form-item
key="port"
class="form_item_right"
label="端口"
:label="t('server.form.port')"
prop="port"
>
<el-input
@@ -112,7 +112,7 @@
/>
</el-form-item>
</div>
<el-form-item key="username" label="用户名" prop="username">
<el-form-item key="username" :label="t('server.form.username')" prop="username">
<el-autocomplete
v-model.trim="hostForm.username"
:fetch-suggestions="userSearch"
@@ -127,21 +127,21 @@
<el-form-item
v-if="isSSH"
key="authType"
label="认证方式"
:label="t('server.form.authType')"
prop="authType"
>
<el-radio v-model="hostForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model="hostForm.authType" value="credential">凭据</el-radio>
<el-radio v-model="hostForm.authType" value="privateKey">{{ t('server.privateKey') }}</el-radio>
<el-radio v-model="hostForm.authType" value="password">{{ t('server.password') }}</el-radio>
<el-radio v-model="hostForm.authType" value="credential">{{ t('menu.credentials') }}</el-radio>
</el-form-item>
<el-form-item
v-if="isSSH && hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
:label="t('server.privateKey')"
>
<el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥...
{{ t('credentials.localPrivateKey') }}
</el-button>
<input
ref="privateKeyRef"
@@ -162,16 +162,16 @@
/>
<div v-if="hasEncryptedKey" class="key_warning">
<el-icon><WarningFilled /></el-icon>
<span>如果该密钥需密码,需在</span>
<a href="javascript:void(0)" @click="toCredentials">凭据管理</a>
<span>中添加,未加密私钥可忽略此提示.</span>
<span>{{ t('server.form.encryptedKeyPrefix') }}</span>
<a href="javascript:void(0)" @click="toCredentials">{{ t('menu.credentials') }}</a>
<span>{{ t('server.form.encryptedKeySuffix') }}</span>
</div>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'password' || isRDP"
key="password"
prop="password"
label="密码"
:label="t('server.password')"
>
<el-input
v-model.trim="hostForm.password"
@@ -186,14 +186,14 @@
v-if="isSSH && hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
:label="t('menu.credentials')"
>
<el-select v-model="hostForm.credential" placeholder="">
<template #empty>
<div class="empty_text">
<span>无凭据数据,</span>
<span>{{ t('server.form.noCredentialData') }}</span>
<el-button type="primary" link @click="toCredentials">
去添加
{{ t('server.form.goAdd') }}
</el-button>
</div>
</template>
@@ -206,25 +206,25 @@
<div class="select_warp">
<span>{{ item.name }}</span>
<span class="auth_type_text">
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
{{ item.authType === 'privateKey' ? t('server.privateKey') : t('server.password') }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-collapse v-model="advancedSettingsCollapsed" accordion>
<el-collapse-item name="advanced" title="其他设置">
<el-collapse-item name="advanced" :title="t('terminal.otherSettings')">
<PlusSupportTip>
<el-form-item
v-if="isSSH"
key="proxyType"
label="代理类型"
:label="t('server.proxyType')"
prop="proxyType"
>
<el-radio-group v-model="hostForm.proxyType" :disabled="!isPlusActive">
<el-radio value="">不使用代理</el-radio>
<el-radio value="proxyServer">代理服务</el-radio>
<el-radio value="jumpHosts">跳板机</el-radio>
<el-radio value="">{{ t('server.form.noProxy') }}</el-radio>
<el-radio value="proxyServer">{{ t('server.proxyServerLabel') }}</el-radio>
<el-radio value="jumpHosts">{{ t('server.jumpHost') }}</el-radio>
</el-radio-group>
</el-form-item>
</PlusSupportTip>
@@ -232,17 +232,17 @@
v-if="isSSH && hostForm.proxyType === 'jumpHosts'"
key="jumpHosts"
prop="jumpHosts"
label="跳板机"
:label="t('server.jumpHost')"
>
<el-select
v-model="hostForm.jumpHosts"
placeholder="支持多选,跳板机连接顺序从前到后"
:placeholder="t('server.form.jumpHostPlaceholder')"
multiple
:disabled="!isPlusActive"
>
<template #empty>
<div class="empty_text">
<span>无可用跳板机器</span>
<span>{{ t('server.form.noJumpHost') }}</span>
</div>
</template>
<el-option
@@ -261,7 +261,7 @@
v-if="isSSH && hostForm.proxyType === 'proxyServer'"
key="proxyServer"
prop="proxyServer"
label="代理服务"
:label="t('server.proxyServerLabel')"
>
<el-select
v-model="hostForm.proxyServer"
@@ -270,9 +270,9 @@
>
<template #empty>
<div class="empty_text">
<span>无可用代理服务,</span>
<span>{{ t('server.form.noProxyService') }}</span>
<el-button type="primary" link @click="toProxy">
去添加
{{ t('server.form.goAdd') }}
</el-button>
</div>
</template>
@@ -295,7 +295,7 @@
v-if="isSSH"
key="command"
prop="command"
label="登录指令"
:label="t('server.form.loginCommand')"
>
<el-input
v-model="hostForm.command"
@@ -303,40 +303,40 @@
:rows="3"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
:placeholder="t('server.form.loginCommandPlaceholder')"
/>
</el-form-item>
<el-form-item key="expired" label="到期时间" prop="expired">
<el-form-item key="expired" :label="t('server.expiredAt')" prop="expired">
<el-date-picker
v-model="hostForm.expired"
type="date"
:editable="false"
style="width: 100%;"
value-format="x"
placeholder="实例到期时间"
:placeholder="t('server.form.expiredPlaceholder')"
/>
</el-form-item>
<el-form-item
v-if="isValidDate(hostForm.expired)"
key="expiredNotify"
label="到期提醒"
:label="t('server.form.expiredNotify')"
prop="expiredNotify"
>
<el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
<el-tooltip :content="t('server.form.expiredNotifyTip')" placement="right">
<el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" />
</el-tooltip>
</el-form-item>
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
<el-form-item key="consoleUrl" :label="t('server.consoleUrl')" prop="consoleUrl">
<el-input
v-model.trim="hostForm.consoleUrl"
clearable
placeholder="用于直达云服务控制台"
:placeholder="t('server.form.consoleUrlPlaceholder')"
autocomplete="off"
@keyup.enter="handleSave"
/>
</el-form-item>
<el-form-item key="tag" label="标签" prop="tag">
<el-form-item key="tag" :label="t('server.tag')" prop="tag">
<el-input-tag v-model="hostForm.tag" tag-type="success" tag-effect="plain" />
</el-form-item>
</el-collapse-item>
@@ -354,10 +354,10 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button v-if="!isBatchModify" type="primary" @click="handleSave">确认</el-button>
<el-button @click="visible = false">{{ t('common.close') }}</el-button>
<el-button v-if="!isBatchModify" type="primary" @click="handleSave">{{ t('common.confirm') }}</el-button>
<PlusSupportTip v-else>
<el-button type="primary" :disabled="!isPlusActive" @click="handleSave">确认</el-button>
<el-button type="primary" :disabled="!isPlusActive" @click="handleSave">{{ t('common.confirm') }}</el-button>
</PlusSupportTip>
</span>
</template>
@@ -366,12 +366,14 @@
<script setup>
import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
import { isValidDate } from '@/utils'
import { WarningFilled } from '@element-plus/icons-vue'
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -434,13 +436,13 @@ const batchHosts = computed(() => props.batchHosts)
const defaultData = computed(() => props.defaultData)
const rules = computed(() => {
return {
connectType: { required: true, message: '选择一个连接类型' },
group: { required: !isBatchModify.value, message: '选择一个分组' },
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
connectType: { required: true, message: t('server.form.validation.connectType') },
group: { required: !isBatchModify.value, message: t('server.form.validation.group') },
name: { required: !isBatchModify.value, message: t('server.form.validation.name'), trigger: 'change' },
host: { required: !isBatchModify.value, message: t('server.form.validation.host'), trigger: 'change' },
port: { required: !isBatchModify.value, type: 'number', message: t('server.form.validation.port'), trigger: 'change' },
jumpHosts: { required: false, type: 'array' },
index: { required: !isBatchModify.value, type: 'number', message: '输入数字', trigger: 'change' },
index: { required: !isBatchModify.value, type: 'number', message: t('server.group.numberRequired'), trigger: 'change' },
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
expired: { required: false },
@@ -457,7 +459,7 @@ const visible = computed({
})
const title = computed(() => {
return isBatchModify.value ? '批量修改实例' : (defaultData.value ? '修改实例' : '添加实例')
return isBatchModify.value ? t('server.batchModify') : (defaultData.value ? t('server.form.editTitle') : t('server.form.addTitle'))
})
// 连接类型计算属性
@@ -604,7 +606,7 @@ const handleSave = () => {
delete updateFieldData.password
delete updateFieldData.credential
}
if (Object.keys(updateFieldData).length === 0) return $message.warning('没有任何修改')
if (Object.keys(updateFieldData).length === 0) return $message.warning(t('server.form.noChanges'))
console.log(updateFieldData)
if (updateFieldData.authType) {
let tempKey = randomStr(16)
+30 -28
View File
@@ -12,13 +12,13 @@
<el-table-column
v-if="props.columnSettings.index"
property="index"
label="序号"
:label="t('server.index')"
sortable
width="100px"
/>
<el-table-column
v-if="props.columnSettings.name"
label="名称"
:label="t('common.name')"
property="name"
sortable
:sort-method="(a, b) => a.name - b.name"
@@ -34,30 +34,30 @@
</span>
</template>
</el-table-column>
<el-table-column v-if="props.columnSettings.username" property="username" label="用户名" />
<el-table-column v-if="props.columnSettings.host" property="host" label="IP">
<el-table-column v-if="props.columnSettings.username" property="username" :label="t('server.username')" />
<el-table-column v-if="props.columnSettings.host" property="host" :label="t('server.ip')">
<template #default="scope">
<span @click="handleCopy(scope.row.host)">{{ scope.row.host }}</span>
</template>
</el-table-column>
<el-table-column v-if="props.columnSettings.port" property="port" label="端口" />
<el-table-column v-if="props.columnSettings.authType" property="port" label="认证类型">
<template #default="scope">{{ scope.row.authType === 'password' ? '密码' : '密钥' }}</template>
<el-table-column v-if="props.columnSettings.port" property="port" :label="t('server.port')" />
<el-table-column v-if="props.columnSettings.authType" property="port" :label="t('server.authType')">
<template #default="scope">{{ scope.row.authType === 'password' ? t('server.password') : t('server.privateKey') }}</template>
</el-table-column>
<el-table-column
v-if="props.columnSettings.proxyType"
property="port"
show-overflow-tooltip
label="代理类型"
:label="t('server.proxyType')"
>
<template #default="scope">{{ formatProxyType(scope.row) }}</template>
</el-table-column>
<el-table-column v-if="props.columnSettings.expired" property="expired" label="到期时间" sortable />
<el-table-column v-if="props.columnSettings.expired" property="expired" :label="t('server.expiredAt')" sortable />
<el-table-column
v-if="props.columnSettings.consoleUrl"
property="consoleUrl"
show-overflow-tooltip
label="控制台URL"
:label="t('server.consoleUrl')"
>
<template #default="scope">
<span v-if="scope.row.consoleUrl" class="link" @click="handleToConsole(scope.row)">{{ scope.row.consoleUrl }}</span>
@@ -68,7 +68,7 @@
v-if="props.columnSettings.tag"
show-overflow-tooltip
property="tag"
label="标签"
:label="t('server.tag')"
>
<template #default="scope">
<span v-if="scope.row.tag?.length">
@@ -85,11 +85,11 @@
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" :width="isMobileScreen ? 'auto' : '260px'">
<el-table-column :label="t('server.group.actions')" fixed="right" :width="isMobileScreen ? 'auto' : '260px'">
<template #default="{ row }">
<el-dropdown v-if="isMobileScreen" trigger="click">
<span class="link">
操作
{{ t('server.group.actions') }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
@@ -100,17 +100,17 @@
<el-tooltip
:disabled="row.isConfig"
effect="dark"
content="请先配置ssh连接信息"
:content="t('server.configSshFirst')"
placement="left"
>
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">连接</el-button>
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">{{ t('server.connect') }}</el-button>
</el-tooltip>
</el-dropdown-item>
<el-dropdown-item>
<el-button type="primary" @click="handleUpdate(row)">配置</el-button>
<el-button type="primary" @click="handleUpdate(row)">{{ t('server.config') }}</el-button>
</el-dropdown-item>
<el-dropdown-item>
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
<el-button type="danger" @click="handleRemoveHost(row)">{{ t('server.delete') }}</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -119,13 +119,13 @@
<el-tooltip
:disabled="row.isConfig"
effect="dark"
content="请先配置ssh连接信息"
:content="t('server.configSshFirst')"
placement="left"
>
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">连接</el-button>
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">{{ t('server.connect') }}</el-button>
</el-tooltip>
<el-button type="primary" @click="handleUpdate(row)">配置</el-button>
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
<el-button type="primary" @click="handleUpdate(row)">{{ t('server.config') }}</el-button>
<el-button type="danger" @click="handleRemoveHost(row)">{{ t('server.delete') }}</el-button>
</template>
</template>
</el-table-column>
@@ -135,11 +135,13 @@
<script setup>
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown } from '@element-plus/icons-vue'
import useMobileWidth from '@/composables/useMobileWidth'
import clipboard from '@/utils/clipboard'
const { proxy: { $message, $messageBox, $api, $router, $store } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
hosts: {
@@ -178,7 +180,7 @@ const handleUpdate = (hostInfo) => {
}
const handleToConsole = ({ consoleUrl }) => {
if (!consoleUrl) return $message({ message: '未配置服务商控制台地址', type: 'warning', center: true })
if (!consoleUrl) return $message({ message: t('server.noConsoleUrl'), type: 'warning', center: true })
window.open(consoleUrl)
}
@@ -231,9 +233,9 @@ defineExpose({
})
const handleRemoveHost = async ({ id }) => {
$messageBox.confirm('确认删除实例', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('server.deleteInstanceConfirm'), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(async () => {
let { data } = await $api.removeHost({ ids: [id,] })
@@ -252,19 +254,19 @@ const handleCopy = (host) => {
}
const formatProxyType = ({ proxyType, jumpHosts, proxyServer }) => {
if (!proxyType) return '--'
if (!proxyType) return t('scripts.noAction')
if (proxyType === 'jumpHosts' && jumpHosts?.length > 0) {
const jumpHostsName = jumpHosts.map(item => {
const hostInfo = hostList.value.find(host => host.id === item)
return hostInfo?.name || 'Error'
}).join('>>>')
return `[跳板机]${ jumpHostsName }`
return `[${ t('server.jumpHost') }]${ jumpHostsName }`
}
if (proxyType === 'proxyServer' && proxyList.value.some(item => item.id === proxyServer)) {
const proxyServerInfo = proxyList.value.find(item => item.id === proxyServer)
return `[${ proxyServerInfo.type }]${ proxyServerInfo.name }`
}
return '--'
return t('scripts.noAction')
}
</script>
@@ -5,10 +5,10 @@
top="225px"
modal-class="import_form_dialog"
append-to-body
title="导入实例配置"
:title="t('server.import.title')"
:close-on-click-modal="false"
>
<h2>选择要导入的文件类型</h2>
<h2>{{ t('server.import.chooseFileType') }}</h2>
<ul class="type_list">
<li @click="handleFromCsv">
<svg-icon name="icon-csv" class="icon" />
@@ -50,9 +50,11 @@
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { parse } from 'csv-parse/browser/esm/sync'
const { proxy: { $api, $message } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -89,7 +91,7 @@ const handleCsvFile = (event) => {
return
}
const csvFiles = [...files,].filter(file => file.type === 'text/csv')
if (csvFiles.length === 0) return $message.warning('未选择有效的CSV文件')
if (csvFiles.length === 0) return $message.warning(t('server.import.invalidCsv'))
let readerPromises = csvFiles.map(file => {
return new Promise((resolve, reject) => {
@@ -123,7 +125,7 @@ const handleCsvFile = (event) => {
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
$message.error(t('server.import.importFailed', { message: error.message }))
console.error('导入失败: ', error)
})
.finally(() => {
@@ -134,7 +136,7 @@ const handleCsvFile = (event) => {
const handleJsonFile = (event) => {
let files = event.target.files
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
if (jsonFiles.length === 0) return $message.warning(t('server.import.invalidJson'))
let readerPromises = jsonFiles.map(file => {
return new Promise((resolve, reject) => {
@@ -166,7 +168,7 @@ const handleJsonFile = (event) => {
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
$message.error(t('server.import.importFailed', { message: error.message }))
console.error('导入失败: ', error)
})
.finally(() => {
@@ -178,11 +180,11 @@ async function handleImportHost(importHost) {
// console.log('导入: ', importHost)
try {
let { data: { len } } = await $api.importHost({ importHost, isEasyNodeJson: isEasyNodeJson.value })
$message({ type: 'success', center: true, message: `成功导入实例: ${ len }台` })
$message({ type: 'success', center: true, message: t('server.import.importSuccess', { count: len }) })
emit('update-list')
visible.value = false
} catch (error) {
$message.error('导入失败:', error.message)
$message.error(t('server.import.importFailed', { message: error.message }))
}
}
@@ -1,14 +1,14 @@
<template>
<el-dialog
v-model="visible"
title="列表设置"
:title="t('server.display.title')"
width="500px"
append-to-body
@close="handleClose"
>
<el-tabs v-model="activeTab">
<!-- 表头设置 Tab -->
<el-tab-pane label="表头设置" name="columns">
<el-tab-pane :label="t('server.display.columns')" name="columns">
<div class="column_settings">
<div v-for="(item, key) in columnConfig" :key="key" class="column_item">
<el-checkbox
@@ -23,7 +23,7 @@
</el-tab-pane>
<!-- 展现形式 Tab -->
<el-tab-pane label="展现形式" name="display">
<el-tab-pane :label="t('server.display.mode')" name="display">
<div class="display_settings">
<div class="display_mode_cards">
<div
@@ -32,8 +32,8 @@
@click="localDisplayMode = 'group'"
>
<div class="card_content">
<div class="card_title">分组展示</div>
<div class="card_description">按照分组折叠展示主机列表</div>
<div class="card_title">{{ t('server.display.groupView') }}</div>
<div class="card_description">{{ t('server.display.groupViewDesc') }}</div>
</div>
<div class="card_check">
<el-icon v-if="localDisplayMode === 'group'" class="check_icon">
@@ -48,8 +48,8 @@
@click="localDisplayMode = 'list'"
>
<div class="card_content">
<div class="card_title">列表展示</div>
<div class="card_description">在一个列表中展示所有主机</div>
<div class="card_title">{{ t('server.display.listView') }}</div>
<div class="card_description">{{ t('server.display.listViewDesc') }}</div>
</div>
<div class="card_check">
<el-icon v-if="localDisplayMode === 'list'" class="check_icon">
@@ -64,8 +64,8 @@
<template #footer>
<div class="dialog_footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
<el-button @click="handleClose">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ t('common.confirm') }}</el-button>
</div>
</template>
</el-dialog>
@@ -73,6 +73,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CircleCheckFilled } from '@element-plus/icons-vue'
const props = defineProps({
@@ -95,6 +96,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:show', 'confirm',])
const { t } = useI18n()
const visible = computed({
get: () => props.show,
+43 -41
View File
@@ -4,7 +4,7 @@
<el-input
v-if="hostList.length > 2"
v-model="searchKeyword"
placeholder="搜索实例"
:placeholder="t('server.searchInstance')"
class="search_input"
clearable
>
@@ -18,38 +18,38 @@
:class="{ 'first_btn': hostList.length <= 2 }"
@click="hostFormVisible = true"
>
添加实例
{{ t('server.addInstance') }}
</el-button>
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
批量操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
{{ t('server.batchActions') }}<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleBatchConnect">连接终端</el-dropdown-item>
<el-dropdown-item @click="handleBatchModify">批量修改</el-dropdown-item>
<el-dropdown-item @click="handleBatchRemove">批量删除</el-dropdown-item>
<el-dropdown-item @click="handleSelectAll">反选所有</el-dropdown-item>
<el-dropdown-item @click="importVisible = true">导入实例</el-dropdown-item>
<el-dropdown-item @click="handleBatchExport">导出实例</el-dropdown-item>
<el-dropdown-item @click="handleBatchConnect">{{ t('server.connectTerminal') }}</el-dropdown-item>
<el-dropdown-item @click="handleBatchModify">{{ t('server.batchModify') }}</el-dropdown-item>
<el-dropdown-item @click="handleBatchRemove">{{ t('server.batchDelete') }}</el-dropdown-item>
<el-dropdown-item @click="handleSelectAll">{{ t('server.invertSelection') }}</el-dropdown-item>
<el-dropdown-item @click="importVisible = true">{{ t('server.importInstance') }}</el-dropdown-item>
<el-dropdown-item @click="handleBatchExport">{{ t('server.exportInstance') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" @click="groupDialogVisible = true">分组管理</el-button>
<el-button type="primary" @click="groupDialogVisible = true">{{ t('server.groupManagement') }}</el-button>
<el-button
type="primary"
class="table_header_setting_btn"
@click="listSettingsVisible = true"
>
列表设置
{{ t('server.listSettings') }}
</el-button>
</div>
<div class="server_group_collapse">
<div v-if="isNoHost">
<el-empty description="暂无实例">
<el-button type="primary" @click="hostFormVisible = true">添加实例配置</el-button>
<span class="or">或</span>
<el-button type="primary" @click="importVisible = true">批量导入实例</el-button>
<el-empty :description="t('server.empty')">
<el-button type="primary" @click="hostFormVisible = true">{{ t('server.addInstanceConfig') }}</el-button>
<span class="or">{{ t('server.or') }}</span>
<el-button type="primary" @click="importVisible = true">{{ t('server.batchImportInstance') }}</el-button>
</el-empty>
</div>
<!-- 分组展示模式 -->
@@ -119,6 +119,7 @@
<script setup>
import { h, ref, getCurrentInstance, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
// import HostCard from './components/host-card.vue'
import HostTable from './components/host-table.vue'
import HostForm from './components/host-form.vue'
@@ -129,6 +130,7 @@ import { ArrowDown, ArrowUp, Search } from '@element-plus/icons-vue'
import { exportFile } from '@/utils'
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
const { t } = useI18n()
const updateHostData = ref(null)
const hostFormVisible = ref(false)
@@ -151,17 +153,17 @@ const scrollContainer = ref(null)
// 列配置定义
const columnConfig = {
selection: { label: '选择', disabled: false },
index: { label: '序号', disabled: false },
name: { label: '名称', disabled: false },
username: { label: '用户名', disabled: false },
host: { label: 'IP', disabled: false },
port: { label: '端口', disabled: false },
authType: { label: '认证类型', disabled: false },
proxyType: { label: '代理类型', disabled: false },
expired: { label: '到期时间', disabled: false },
consoleUrl: { label: '控制台URL', disabled: false },
tag: { label: 'Tag', disabled: false }
selection: { label: t('server.selection'), disabled: false },
index: { label: t('server.index'), disabled: false },
name: { label: t('common.name'), disabled: false },
username: { label: t('server.username'), disabled: false },
host: { label: t('server.ip'), disabled: false },
port: { label: t('server.port'), disabled: false },
authType: { label: t('server.authType'), disabled: false },
proxyType: { label: t('server.proxyType'), disabled: false },
expired: { label: t('server.expiredAt'), disabled: false },
consoleUrl: { label: t('server.consoleUrl'), disabled: false },
tag: { label: t('server.tag'), disabled: false }
}
// 列设置状态(从store获取)
@@ -172,9 +174,9 @@ const handleSettingsConfirm = async (settings) => {
try {
// 保存到数据库
await $store.setServerListConfig(settings)
$message.success('设置已保存')
$message.success(t('server.settingsSaved'))
} catch (error) {
$message.error('保存设置失败')
$message.error(t('server.saveSettingsFailed'))
console.error('保存设置失败:', error)
}
}
@@ -183,7 +185,7 @@ const handleUpdateList = async () => {
try {
await $store.getHostList()
} catch (err) {
$message.error('获取实例列表失败')
$message.error(t('common.fetchServerListFailed'))
console.error('获取实例列表失败: ', err)
}
}
@@ -206,24 +208,24 @@ const collectSelectHost = () => {
const handleBatchConnect = () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
if (!selectHosts.value.length) return $message.warning(t('server.selectInstancesFirst'))
let ids = selectHosts.value.filter(item => item.isConfig).map(item => item.id)
if (!ids.length) return $message.warning('所选实例未配置ssh连接信息')
if (!ids.length) return $message.warning(t('server.sshConfigMissing'))
// $router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
if (selectHosts.value.every(item => item.connectType === 'rdp')) {
$router.push({ path: '/rdp', query: { hostIds: ids.join(',') } })
} else if (selectHosts.value.every(item => !item.connectType || item.connectType === 'ssh')) {
$router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
} else {
$message.warning('所选实例包含rdp和ssh连接信息,请选择同一终端类型进行批量连接')
$message.warning(t('server.mixedTerminalType'))
return
}
if (ids.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
if (ids.length < selectHosts.value.length) $message.warning(t('server.partialIgnored'))
}
const handleBatchModify = async () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
if (!selectHosts.value.length) return $message.warning(t('server.selectInstancesFirst'))
isBatchModify.value = true
hostFormVisible.value = true
}
@@ -238,13 +240,13 @@ const handleSelectAll = () => {
const handleBatchRemove = async () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
if (!selectHosts.value.length) return $message.warning(t('server.selectInstancesFirst'))
let ids = selectHosts.value.map(item => item.id)
let names = selectHosts.value.map(item => item.name)
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, t('server.deleteBatchConfirm', { names: names.join(', ') })), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(async () => {
let { data } = await $api.removeHost({ ids })
@@ -262,7 +264,7 @@ const handleUpdateHost = (defaultData) => {
const handleBatchExport = () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
if (!selectHosts.value.length) return $message.warning(t('server.selectInstancesFirst'))
let exportData = JSON.parse(JSON.stringify(selectHosts.value))
exportData = exportData.map(item => {
delete item.monitorData
@@ -307,8 +309,8 @@ const groupHostList = computed(() => {
if (group?.name) {
res[group.name].push(item)
} else {
if (!res['默认分组']) res['默认分组'] = []
res['默认分组'].push(item)
if (!res[t('server.groupDefaultName')]) res[t('server.groupDefaultName')] = []
res[t('server.groupDefaultName')].push(item)
}
})
Object.keys(res).map(groupName => {
+52 -71
View File
@@ -9,7 +9,7 @@
label-width="120px"
label-suffix=":"
>
<el-form-item label="通知方式" prop="type" class="form_item">
<el-form-item :label="t('settings.notify.notifyMethod')" prop="type" class="form_item">
<el-select v-model="noticeConfig.type" placeholder="" class="input">
<el-option
v-for="item in noticeTypeList"
@@ -19,7 +19,6 @@
/>
</el-select>
</el-form-item>
<!-- server酱 -->
<template v-if="noticeConfig.type === 'sct'">
<el-form-item label="SendKey" prop="sct.sendKey" class="form_item">
<el-input
@@ -29,34 +28,30 @@
autocomplete="off"
class="input"
/>
<p class="tips">普通用户每日最多支持5条,有条件建议开通会员服务防止丢失重要通知。<a class="link" href="https://sct.ftqq.com/r/9338" target="_blank">Server酱官网</a> </p>
<p class="tips">{{ t('settings.notify.sctTip') }}<a class="link" href="https://sct.ftqq.com/r/9338" target="_blank">{{ t('settings.notify.serverChanOfficial') }}</a></p>
</el-form-item>
</template>
<!-- 邮箱 -->
<template v-if="noticeConfig.type === 'email'">
<!-- 配置模式切换 -->
<el-form-item label="配置模式" class="form_item">
<el-form-item :label="t('settings.notify.configMode')" class="form_item">
<el-radio-group v-model="noticeConfig.email.useCustom" @change="handleEmailModeChange">
<el-radio-button :value="false">服务商</el-radio-button>
<el-radio-button :value="true">自定义 SMTP</el-radio-button>
<el-radio-button :value="false">{{ t('settings.notify.serviceProvider') }}</el-radio-button>
<el-radio-button :value="true">{{ t('settings.notify.customSmtp') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 服务商模式 -->
<template v-if="!noticeConfig.email.useCustom">
<el-form-item label="服务商" prop="email.service" class="form_item">
<el-form-item :label="t('settings.notify.serviceProvider')" prop="email.service" class="form_item">
<el-input
v-model.trim="noticeConfig.email.service"
clearable
placeholder="QQ、126、163、Gmail..."
:placeholder="t('settings.notify.emailProviderPlaceholder')"
autocomplete="off"
class="input"
/>
<span class="tips">邮箱服务商, 例如: QQ、126、163、Gmail, 支持列表: <a class="link" href="https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json" target="_blank">点击查询</a> </span>
<span class="tips">{{ t('settings.notify.emailProviderTipPrefix') }}<a class="link" href="https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json" target="_blank">{{ t('settings.notify.clickToView') }}</a> </span>
</el-form-item>
</template>
<!-- 自定义 SMTP 模式 -->
<template v-else>
<el-form-item label="SMTP 服务器" prop="email.host" class="form_item">
<el-form-item :label="t('settings.notify.smtpServer')" prop="email.host" class="form_item">
<el-input
v-model.trim="noticeConfig.email.host"
clearable
@@ -64,9 +59,9 @@
autocomplete="off"
class="input"
/>
<span class="tips">SMTP 服务器地址,例如:smtp.gmail.com、smtp.163.com</span>
<span class="tips">{{ t('settings.notify.smtpServerTip') }}</span>
</el-form-item>
<el-form-item label="端口" class="form_item">
<el-form-item :label="t('settings.notify.port')" class="form_item">
<el-select
v-model="noticeConfig.email.port"
placeholder=""
@@ -75,19 +70,18 @@
>
<el-option label="465 (SSL)" :value="465" />
<el-option label="587 (STARTTLS)" :value="587" />
<el-option label="25 (无加密)" :value="25" />
<el-option :label="t('settings.notify.port25NoEncryption')" :value="25" />
</el-select>
</el-form-item>
<el-form-item label="加密连接" class="form_item">
<el-form-item :label="t('settings.notify.secureConnection')" class="form_item">
<el-radio-group v-model="noticeConfig.email.secure">
<el-radio-button :value="true">SSL/TLS</el-radio-button>
<el-radio-button :value="false">STARTTLS/无</el-radio-button>
<el-radio-button :value="false">{{ t('settings.notify.starttlsOrNone') }}</el-radio-button>
</el-radio-group>
<span class="tips">端口 465 通常使用 SSL/TLS,端口 587 使用 STARTTLS</span>
<span class="tips">{{ t('settings.notify.secureConnectionTip') }}</span>
</el-form-item>
</template>
<!-- 通用字段 -->
<el-form-item label="邮箱地址" prop="email.user" class="form_item">
<el-form-item :label="t('settings.notify.emailAddress')" prop="email.user" class="form_item">
<el-input
v-model.trim="noticeConfig.email.user"
clearable
@@ -96,28 +90,27 @@
class="input"
/>
</el-form-item>
<el-form-item label="SMTP 密码" prop="email.pass" class="form_item">
<el-form-item :label="t('settings.notify.smtpPassword')" prop="email.pass" class="form_item">
<el-input
v-model.trim="noticeConfig.email.pass"
clearable
type="password"
show-password
placeholder="SMTP 授权码/密码"
:placeholder="t('settings.notify.smtpPasswordPlaceholder')"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="收件人" class="form_item">
<el-form-item :label="t('settings.notify.recipient')" class="form_item">
<el-input
v-model.trim="noticeConfig.email.to"
clearable
placeholder="留空则发送给自己"
:placeholder="t('settings.notify.recipientPlaceholder')"
autocomplete="off"
class="input"
/>
</el-form-item>
</template>
<!-- Telegram -->
<template v-if="noticeConfig.type === 'tg'">
<el-form-item label="Token" prop="tg.token" class="form_item">
<el-input
@@ -136,10 +129,9 @@
autocomplete="off"
class="input"
/>
<span class="tips">Telegram Token/ChatId 获取: <a class="link" href="https://easynode.chaoszhu.com/zh/guide/get-tg-token" target="_blank">查看教程</a> </span>
<span class="tips">{{ t('settings.notify.telegramTipPrefix') }}<a class="link" href="https://easynode.chaoszhu.com/zh/guide/get-tg-token" target="_blank">{{ t('settings.notify.viewTutorial') }}</a> </span>
</el-form-item>
</template>
<!-- Webhook -->
<template v-if="noticeConfig.type === 'webhook'">
<el-form-item label="URL" prop="webhook.url" class="form_item">
<el-input
@@ -150,7 +142,7 @@
class="input"
/>
</el-form-item>
<el-form-item label="请求方法" class="form_item">
<el-form-item :label="t('settings.notify.requestMethod')" class="form_item">
<el-select v-model="noticeConfig.webhook.method" placeholder="" class="input">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
@@ -170,29 +162,24 @@
v-model="noticeConfig.webhook.headers"
type="textarea"
:rows="2"
placeholder="JSON 格式,可选。例如:{&quot;Authorization&quot;: &quot;Bearer xxx&quot;}"
:placeholder="t('settings.notify.headersPlaceholder')"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="自定义模板" class="form_item">
<el-form-item :label="t('settings.notify.customTemplate')" class="form_item">
<div class="template-wrapper">
<el-input
v-model="noticeConfig.webhook.template"
type="textarea"
:rows="6"
placeholder="{
&quot;msgtype&quot;: &quot;text&quot;,
&quot;text&quot;: {
&quot;content&quot;: &quot;{{title}}\n{{content}}&quot;
}
}"
:placeholder="t('settings.notify.templatePlaceholder')"
autocomplete="off"
class="input"
/>
<el-button size="small" class="format-btn" @click="formatTemplate">格式化</el-button>
<el-button size="small" class="format-btn" @click="formatTemplate">{{ t('settings.notify.format') }}</el-button>
</div>
<span class="tips">支持变量: <code v-pre>{{title}}</code>, <code v-pre>{{content}}</code>, <code v-pre>{{timestamp}}</code>, <code v-pre>{{datetime}}</code>。留空则使用默认格式</span>
<span class="tips">{{ t('settings.notify.templateVariablesTip') }}</span>
</el-form-item>
</template>
<el-form-item label="" class="form_item">
@@ -201,18 +188,18 @@
:loading="loading"
@click="handleSave"
>
测试并保存
{{ t('settings.notify.testAndSave') }}
</el-button>
</el-form-item>
</el-form>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> 请确保通知方式能够正常收到测试通知 </span>
<span style="letter-spacing: 2px;"> {{ t('settings.notify.testNoticeHint') }} </span>
</template>
</el-alert>
<el-table v-loading="notifyListLoading" :data="notifyList">
<el-table-column prop="desc" label="通知类型" />
<el-table-column prop="sw" label="开关">
<el-table-column prop="desc" :label="t('settings.notify.notificationType')" />
<el-table-column prop="sw" :label="t('settings.notify.switch')">
<template #default="{row}">
<el-switch
v-model="row.sw"
@@ -229,8 +216,10 @@
<script setup>
import { ref, onMounted, computed, reactive } from 'vue'
import { getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
const { proxy: { $api, $store, $notification, $message } } = getCurrentInstance()
const { t } = useI18n()
const notifyListLoading = ref(false)
const notifyList = ref([])
@@ -239,11 +228,11 @@ const noticeConfig = ref({})
const noticeTypeList = ref([
{
type: 'email',
desc: '邮箱'
desc: t('settings.notify.email')
},
{
type: 'sct',
desc: 'Server酱'
desc: t('settings.notify.serverChan')
},
{
type: 'tg',
@@ -256,30 +245,28 @@ const noticeTypeList = ref([
])
const formRef = ref(null)
const rules = reactive({
'sct.sendKey': { required: true, message: '需输入sendKey', trigger: 'change' },
'email.service': { required: true, message: '需输入邮箱提供商', trigger: 'change' },
'email.host': { required: true, message: '需输入SMTP服务器地址', trigger: 'change' },
'email.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
'email.pass': { required: true, message: '需输入邮箱SMTP授权码', trigger: 'change' },
'tg.token': { required: true, message: '需输入Telegram Token', trigger: 'change' },
'sct.sendKey': { required: true, message: t('settings.notify.validation.enterSendKey'), trigger: 'change' },
'email.service': { required: true, message: t('settings.notify.validation.enterEmailProvider'), trigger: 'change' },
'email.host': { required: true, message: t('settings.notify.validation.enterSmtpHost'), trigger: 'change' },
'email.user': { required: true, type: 'email', message: t('settings.notify.validation.enterEmail'), trigger: 'change' },
'email.pass': { required: true, message: t('settings.notify.validation.enterSmtpPassword'), trigger: 'change' },
'tg.token': { required: true, message: t('settings.notify.validation.enterTelegramToken'), trigger: 'change' },
'tg.chatId': [
{ required: true, message: '需输入Telegram ChatId', trigger: 'change' },
{ required: true, message: t('settings.notify.validation.enterTelegramChatId'), trigger: 'change' },
{
pattern: /^-?\d+$/,
message: 'ChatId必须为数字',
message: t('settings.notify.validation.chatIdMustBeNumber'),
trigger: ['blur', 'change',]
},
],
'webhook.url': [
{ required: true, message: '需输入Webhook URL', trigger: 'change' },
{ type: 'url', message: '请输入有效的URL', trigger: 'change' },
{ required: true, message: t('settings.notify.validation.enterWebhookUrl'), trigger: 'change' },
{ type: 'url', message: t('settings.notify.validation.enterValidUrl'), trigger: 'change' },
]
})
// 邮箱模式切换时初始化默认值
const handleEmailModeChange = (useCustom) => {
if (useCustom) {
// 切换到自定义模式,设置默认值
if (!noticeConfig.value.email.port) {
noticeConfig.value.email.port = 465
}
@@ -289,7 +276,6 @@ const handleEmailModeChange = (useCustom) => {
}
}
// 端口变化时自动调整加密设置
const handlePortChange = (port) => {
if (port === 465) {
noticeConfig.value.email.secure = true
@@ -304,26 +290,23 @@ const handleSave = () => {
formRef.value.validate(async (valid) => {
if (!valid) return
// Webhook 类型时校验 JSON 格式
if (noticeConfig.value.type === 'webhook') {
const { template, headers } = noticeConfig.value.webhook || {}
// 校验自定义模板
if (template) {
try {
JSON.parse(template)
} catch (e) {
$message.error('自定义模板不是有效的 JSON 格式,请检查后重试')
$message.error(t('settings.notify.invalidTemplateJson'))
return
}
}
// 校验自定义请求头
if (headers) {
try {
JSON.parse(headers)
} catch (e) {
$message.error('自定义请求头不是有效的 JSON 格式,请检查后重试')
$message.error(t('settings.notify.invalidHeadersJson'))
return
}
}
@@ -332,10 +315,9 @@ const handleSave = () => {
try {
loading.value = true
await $api.updateNotifyConfig({ noticeConfig: { ...noticeConfig.value } })
// $message.success('保存成功')
$notification.success({
title: '测试通过 | 保存成功',
message: '请确认通知方式是否已收到通知'
title: t('settings.notify.testPassedSaveSuccess'),
message: t('settings.notify.confirmReceiveTest')
})
} catch (error) {
console.error(error)
@@ -371,7 +353,6 @@ const handleChangeSw = async (row) => {
const { type, sw } = row
try {
await $api.updateNotifyList({ type, sw })
// if (this.userEmailList.length === 0) $message.warning('未配置邮箱, 此开关将不会生效')
} finally {
row.loading = false
}
@@ -383,9 +364,9 @@ const formatTemplate = () => {
try {
const parsed = JSON.parse(noticeConfig.value.webhook.template)
noticeConfig.value.webhook.template = JSON.stringify(parsed, null, 2)
$message.success('格式化成功')
$message.success(t('settings.notify.formatSuccess'))
} catch (e) {
$message.warning('格式化失败:模板不是有效的 JSON 格式')
$message.warning(t('settings.notify.formatFailed'))
}
}
@@ -397,7 +378,6 @@ onMounted(() => {
<style lang="scss" scoped>
.form_item {
// width: 350px;
.input {
width: 450px;
}
@@ -416,3 +396,4 @@ onMounted(() => {
}
}
</style>
+52 -59
View File
@@ -8,7 +8,7 @@
:disabled="!isPlusActive"
@click="handleAdd"
>
添加代理
{{ t('settings.proxy.addProxy') }}
</el-button>
</PlusSupportTip>
</div>
@@ -18,43 +18,43 @@
:data="proxyList"
stripe
style="width: 100%"
empty-text="暂无代理数据"
:empty-text="t('settings.proxy.empty')"
>
<el-table-column prop="type" label="类型">
<el-table-column prop="type" :label="t('common.type')">
<template #default="{ row }">
<el-tag :type="row.type === 'socks5' ? 'success' : 'primary'">
{{ row.type === 'socks5' ? 'SOCKS5' : 'HTTP' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" />
<el-table-column prop="host" label="主机" />
<el-table-column prop="port" label="端口" />
<el-table-column prop="username" label="用户名">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="host" :label="t('settings.proxy.host')" />
<el-table-column prop="port" :label="t('settings.proxy.port')" />
<el-table-column prop="username" :label="t('settings.proxy.username')">
<template #default="{ row }">
<span>{{ row.username || '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="password" label="密码">
<el-table-column prop="password" :label="t('settings.proxy.password')">
<template #default="{ row }">
<span @click="handleShowPassword(row)">{{ row.displayPassword || '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间">
<el-table-column prop="createTime" :label="t('settings.proxy.createTime')">
<template #default="{ row }">
<span>{{ dayjs(row.createTime).format('YYYY-MM-DD HH:mm:ss') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column :label="t('settings.session.actions')" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" @click="handleEdit(row)">
编辑
{{ t('settings.proxy.edit') }}
</el-button>
<el-button type="success" @click="handleClone(row)">
克隆
{{ t('settings.proxy.clone') }}
</el-button>
<el-button type="danger" @click="handleDelete(row)">
删除
{{ t('settings.proxy.delete') }}
</el-button>
</template>
</el-table-column>
@@ -74,10 +74,10 @@
label-width="80px"
label-suffix=":"
>
<el-form-item label="类型" prop="type">
<el-form-item :label="t('common.type')" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择代理类型"
:placeholder="t('settings.proxy.selectProxyType')"
style="width: 100%"
clearable
>
@@ -86,44 +86,44 @@
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-form-item :label="t('common.name')" prop="name">
<el-input
v-model.trim="formData.name"
placeholder="请输入代理名称"
:placeholder="t('settings.proxy.enterProxyName')"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="主机" prop="host">
<el-form-item :label="t('settings.proxy.host')" prop="host">
<el-input
v-model.trim="formData.host"
placeholder="请输入主机地址"
:placeholder="t('settings.proxy.enterHost')"
/>
</el-form-item>
<el-form-item label="端口" prop="port">
<el-form-item :label="t('settings.proxy.port')" prop="port">
<el-input
v-model.number="formData.port"
placeholder="请输入端口号"
:placeholder="t('settings.proxy.enterPort')"
type="number"
:min="1"
:max="65535"
/>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-form-item :label="t('settings.proxy.username')" prop="username">
<el-input
v-model.trim="formData.username"
placeholder="可选,请输入用户名"
:placeholder="t('settings.proxy.enterOptionalUsername')"
maxlength="100"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-form-item :label="t('settings.proxy.password')" prop="password">
<el-input
v-model.trim="formData.password"
placeholder="可选,请输入密码"
:placeholder="t('settings.proxy.enterOptionalPassword')"
maxlength="200"
/>
</el-form-item>
@@ -131,8 +131,8 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
<el-button @click="handleCancel">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{ t('common.confirm') }}</el-button>
</div>
</template>
</el-dialog>
@@ -141,16 +141,18 @@
<script setup>
import { ref, reactive, getCurrentInstance, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('添加代理')
const dialogTitle = ref('')
const formRef = ref(null)
const currentEditId = ref(null)
const proxyList = computed(() => {
@@ -164,7 +166,6 @@ const proxyList = computed(() => {
})
const isPlusActive = computed(() => $store.isPlusActive)
// 表单数据
const formData = reactive({
type: 'socks5',
name: '',
@@ -174,25 +175,23 @@ const formData = reactive({
password: ''
})
// 表单验证规则
const rules = reactive({
type: [
{ required: true, message: '请选择代理类型', trigger: 'change' },
{ required: true, message: t('settings.proxy.validation.selectProxyType'), trigger: 'change' },
],
name: [
{ required: true, message: '请输入代理名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度应在 1 到 50 个字符之间', trigger: 'blur' },
{ required: true, message: t('settings.proxy.validation.enterProxyName'), trigger: 'blur' },
{ min: 1, max: 50, message: t('settings.proxy.validation.proxyNameLength'), trigger: 'blur' },
],
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' },
{ required: true, message: t('settings.proxy.validation.enterHost'), trigger: 'blur' },
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号必须是1-65535之间的整数', trigger: 'blur' },
{ required: true, message: t('settings.proxy.validation.enterPort'), trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: t('settings.proxy.validation.portRange'), trigger: 'blur' },
]
})
// 格式化密码显示
const formatPassword = (password) => {
if (!password) return '-'
if (password.length <= 6) {
@@ -219,7 +218,7 @@ const resetForm = () => {
const handleAdd = () => {
resetForm()
dialogTitle.value = '添加代理'
dialogTitle.value = t('settings.proxy.addProxy')
dialogVisible.value = true
}
@@ -234,57 +233,52 @@ const handleEdit = (row) => {
password: row.password || ''
})
currentEditId.value = row.id
dialogTitle.value = '编辑代理'
dialogTitle.value = t('settings.proxy.editProxy')
dialogVisible.value = true
}
const handleDelete = async (row) => {
try {
await $messageBox.confirm(
`确定要删除代理"${ row.name }"吗?`,
'删除确认',
t('settings.proxy.confirmDelete', { name: row.name }),
t('settings.proxy.deleteConfirmTitle'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
await $api.removeProxy(row.id)
$message.success('删除成功')
$message.success(t('settings.proxy.deleteSuccess'))
await $store.getProxyList()
await $store.getHostList() // 后台会移除所有使用该代理的实例的proxyServer字段
await $store.getHostList()
} catch (error) {
// 如果是用户取消操作,不显示错误
if (error === 'cancel') {
return
}
console.error('删除代理失败:', error)
$message.error('删除代理失败')
$message.error(t('settings.proxy.deleteFailed'))
}
}
const handleClone = async (row) => {
try {
// 构造克隆数据
const cloneData = {
type: row.type,
name: `${ row.name }_克隆`,
name: `${ row.name }_${ t('settings.proxy.cloneSuffix') }`,
host: row.host,
port: row.port,
username: row.username || '',
password: row.password || ''
}
// 直接调用新增接口
await $api.addProxy(cloneData)
$message.success('克隆成功')
// 刷新代理列表
$message.success(t('settings.proxy.cloneSuccess'))
await $store.getProxyList()
} catch (error) {
console.error('克隆代理失败:', error)
$message.error('克隆代理失败')
$message.error(t('settings.proxy.cloneFailed'))
}
}
@@ -301,13 +295,11 @@ const handleSubmit = async () => {
submitLoading.value = true
if (currentEditId.value) {
// 编辑
await $api.updateProxy(currentEditId.value, formData)
$message.success('修改成功')
$message.success(t('settings.proxy.updateSuccess'))
} else {
// 新增
await $api.addProxy(formData)
$message.success('添加成功')
$message.success(t('settings.proxy.addSuccess'))
}
dialogVisible.value = false
@@ -315,7 +307,7 @@ const handleSubmit = async () => {
await $store.getProxyList()
} catch (error) {
console.error('操作失败:', error)
$message.error('操作失败')
$message.error(t('settings.proxy.operationFailed'))
} finally {
submitLoading.value = false
}
@@ -341,3 +333,4 @@ const handleShowPassword = (row) => {
gap: 10px;
}
</style>
+26 -24
View File
@@ -1,11 +1,11 @@
<template>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> 登录白名单IP设置: </span>
<span style="letter-spacing: 2px;"> {{ t('settings.session.ipWhitelistTitle') }} </span>
<el-tooltip placement="top">
<template #content>
<div class="ip_tips">
IP地址为包含匹配, 如输入: 192.168则匹配IP地址包含192.168的所有IP
{{ t('settings.session.ipWhitelistTip') }}
</div>
</template>
<el-icon>
@@ -19,17 +19,16 @@
:loading="btnLoading"
@click="handleSaveAllowedIPs"
>
保存
{{ t('common.save') }}
</el-button>
</template>
</el-alert>
<!-- table -->
<el-table v-loading="loading" :data="loginRecordList">
<el-table-column prop="ip" label="IP" />
<el-table-column
prop="address"
label="地点"
:label="t('settings.session.location')"
min-width="126"
show-overflow-tooltip
>
@@ -39,7 +38,7 @@
</el-table-column>
<el-table-column
prop="agentInfo"
label="设备信息"
:label="t('settings.session.deviceInfo')"
min-width="126"
show-overflow-tooltip
>
@@ -51,24 +50,24 @@
type="success"
size="small"
>
当前设备
{{ t('settings.session.currentDevice') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create" label="登录时间" min-width="126" />
<el-table-column prop="expireAt" label="过期时间" min-width="126">
<el-table-column prop="create" :label="t('settings.session.loginTime')" min-width="126" />
<el-table-column prop="expireAt" :label="t('settings.session.expireTime')" min-width="126">
<template #default="{ row }">
{{ row.expireAt }}
</template>
</el-table-column>
<el-table-column label="状态">
<el-table-column :label="t('common.status')">
<template #default="{ row }">
<el-tag v-if="row.isExpired" type="info" size="small">已过期</el-tag>
<el-tag v-else-if="row.revoked" type="warning" size="small">已注销</el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
<el-tag v-if="row.isExpired" type="info" size="small">{{ t('settings.session.expired') }}</el-tag>
<el-tag v-else-if="row.revoked" type="warning" size="small">{{ t('settings.session.revoked') }}</el-tag>
<el-tag v-else type="success" size="small">{{ t('settings.session.normal') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column :label="t('settings.session.actions')" width="200">
<template #header>
<el-button
type="info"
@@ -76,7 +75,7 @@
:loading="removeLogLoading"
@click="handleRemoveLogs"
>
移除一周前的登录日志
{{ t('settings.session.removeOldLogs') }}
</el-button>
</template>
<template #default="{ row }">
@@ -87,7 +86,7 @@
:loading="removeSidLoading"
@click="handleRemoveSid(row.id)"
>
注销
{{ t('settings.session.revoke') }}
</el-button>
</template>
</el-table-column>
@@ -96,12 +95,14 @@
<script setup>
import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { InfoFilled } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const route = useRoute()
const { t } = useI18n()
const loginRecordList = ref([])
const loading = ref(false)
@@ -146,16 +147,16 @@ const handleSaveAllowedIPs = async () => {
try {
await $api.saveIpWhiteList({ ipWhiteList })
handleLookupLoginRecord()
$message.success('success')
$message.success(t('common.save'))
} finally {
btnLoading.value = false
}
}
const handleRemoveLogs = async () => {
$messageBox.confirm('确定要移除一周前的登录日志吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('settings.session.confirmRemoveOldLogs'), t('settings.common.tip'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -166,7 +167,7 @@ const handleRemoveLogs = async () => {
$message.success(msg)
} catch (error) {
console.error(error)
$message.error('移除一周前的登录日志失败')
$message.error(t('settings.session.removeOldLogsFailed'))
} finally {
removeLogLoading.value = false
}
@@ -174,9 +175,9 @@ const handleRemoveLogs = async () => {
}
const handleRemoveSid = async (id) => {
$messageBox.confirm('确定要注销该设备登录凭证吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('settings.session.confirmRevokeCredential'), t('settings.common.tip'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -200,3 +201,4 @@ onMounted(() => {
margin: 0 5px;
}
</style>
+27 -25
View File
@@ -14,7 +14,7 @@
<el-input
v-model.trim="formData.key"
clearable
placeholder=""
:placeholder="t('settings.plus.plusKey')"
autocomplete="off"
class="input"
@keyup.enter.prevent="handleUpdate"
@@ -22,9 +22,9 @@
</el-form-item>
<el-form-item>
<div class="form_footer">
<el-button type="success" :loading="loading" @click="handleUpdate">立即激活</el-button>
<el-button type="success" :loading="loading" @click="handleUpdate">{{ t('settings.plus.activateNow') }}</el-button>
<el-button type="primary" @click="handlePlusSupport">
获取 Plus Key
{{ t('settings.plus.getPlusKey') }}
<el-icon class="el-icon--right"><TopRight /></el-icon>
</el-button>
<span v-if="!isPlusActive && discount" class="discount_wrapper" @click="handlePlusSupport">
@@ -38,26 +38,25 @@
</div>
</el-form-item>
</el-form>
<!-- Plus 激活状态信息 -->
<div v-if="isPlusActive" class="plus_status">
<div class="status_header">
<span>Plus专属功能已激活</span>
<span>{{ t('settings.plus.activated') }}</span>
</div>
<div class="status_info">
<div class="info_item">
<span class="label">到期时间:</span>
<span class="label">{{ t('settings.plus.expiryDate') }}</span>
<span class="value holder">{{ plusInfo.expiryDate }}</span>
</div>
<div class="info_item">
<span class="label">授权IP数:</span>
<span class="label">{{ t('settings.plus.maxAuthorizedIps') }}</span>
<span class="value">{{ plusInfo.maxIPs }}</span>
</div>
<div class="info_item">
<span class="label">已授权IP数:</span>
<span class="label">{{ t('settings.plus.usedAuthorizedIps') }}</span>
<span class="value">{{ plusInfo.usedIPCount }}</span>
</div>
<div class="info_item ip_list">
<span class="label">已授权IP:</span>
<span class="label">{{ t('settings.plus.authorizedIps') }}</span>
<div class="ip_tags">
<el-tag
v-for="ip in displayedIPs"
@@ -75,7 +74,7 @@
class="view_all_btn"
@click="showAllIPsDialog = true"
>
查看所有({{ totalIPCount }})
{{ t('settings.plus.viewAll', { count: totalIPCount }) }}
</el-button>
<el-button
type="success"
@@ -84,7 +83,7 @@
:loading="whitelistLoading"
@click="handleSetToWhitelist"
>
[追加所有IP到登录白名单]
{{ t('settings.plus.appendAllToWhitelist') }}
</el-button>
</div>
</div>
@@ -94,13 +93,13 @@
<el-dialog
v-model="showAllIPsDialog"
title="所有已授权IP"
:title="t('settings.plus.allAuthorizedIps')"
width="600px"
:before-close="() => showAllIPsDialog = false"
>
<div class="all_ips_container">
<div class="ip_count_info">
共 {{ totalIPCount }} 个已授权IP
{{ t('settings.plus.totalAuthorizedIps', { count: totalIPCount }) }}
</div>
<div class="all_ip_tags">
<el-tag
@@ -114,13 +113,14 @@
</div>
</div>
<template #footer>
<el-button @click="showAllIPsDialog = false">关闭</el-button>
<el-button @click="showAllIPsDialog = false">{{ t('common.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { TopRight } from '@element-plus/icons-vue'
import { handlePlusSupport } from '@/utils'
@@ -129,6 +129,7 @@ import { useRouter } from 'vue-router'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const router = useRouter()
const { t } = useI18n()
const errCount = ref(Number(localStorage.getItem('plusErrCount') || 0))
const loading = ref(false)
@@ -139,7 +140,7 @@ const formData = reactive({
key: ''
})
const rules = reactive({
key: { required: true, message: '输入Plus Key', trigger: 'change' }
key: { required: true, message: t('settings.plus.validation.enterPlusKey'), trigger: 'change' }
})
const discount = ref(false)
const discountContent = ref('')
@@ -168,21 +169,21 @@ const handleUpdate = () => {
loading.value = true
let { key } = formData
if (key.length < 15) {
$message({ type: 'warning', center: true, message: '请输入正确的Plus Key' })
$message({ type: 'warning', center: true, message: t('settings.plus.invalidPlusKey') })
return
}
await $api.updatePlusKey({ key })
$message({ type: 'success', center: true, message: '激活成功,感谢支持' })
$message({ type: 'success', center: true, message: t('settings.plus.activateSuccess') })
localStorage.setItem('plusErrCount', 0)
} catch (error) {
localStorage.setItem('plusErrCount', ++errCount.value)
if (errCount.value > 3) {
ElMessageBox.confirm(
'激活失败,请确认key正确,有疑问请tg联系作者@chaoszhu',
t('settings.plus.activateFailedHelp'),
'Warning',
{
showCancelButton : false,
confirmButtonText: '确认',
confirmButtonText: t('common.confirm'),
type: 'warning'
}
)
@@ -219,11 +220,11 @@ const handleSetToWhitelist = async () => {
try {
const allAuthorizedIPs = plusInfo.value?.usedIPs || []
await ElMessageBox.confirm(
`确定要将 ${ allAuthorizedIPs.length } 个PLUS授权IP追加到登录白名单吗?<br/><span style="color: #ff4806;">注意!</span>设置后非白名单IP将无法访问面板`,
'确认操作',
t('settings.plus.appendWhitelistConfirm', { count: allAuthorizedIPs.length }),
t('settings.plus.confirmAction'),
{
confirmButtonText: '确认追加',
cancelButtonText: '取消',
confirmButtonText: t('settings.plus.confirmAppend'),
cancelButtonText: t('common.cancel'),
type: 'warning',
dangerouslyUseHTMLString: true
}
@@ -237,7 +238,7 @@ const handleSetToWhitelist = async () => {
$message({
type: 'success',
center: true,
message: `成功将 ${ allAuthorizedIPs.length } 个IP添加到登录白名单`
message: t('settings.plus.appendWhitelistSuccess', { count: allAuthorizedIPs.length })
})
setTimeout(() => {
@@ -255,7 +256,7 @@ const handleSetToWhitelist = async () => {
$message({
type: 'error',
center: true,
message: error.message || '设置白名单失败'
message: error.message || t('settings.plus.appendWhitelistFailed')
})
} finally {
whitelistLoading.value = false
@@ -379,3 +380,4 @@ onMounted(() => {
}
</style>
+42 -36
View File
@@ -9,73 +9,77 @@
label-width="86px"
:show-message="false"
>
<el-form-item label="原用户名" prop="oldLoginName" class="form_item">
<el-form-item :label="t('settings.user.oldUsername')" prop="oldLoginName" class="form_item">
<el-input
v-model.trim="formData.oldLoginName"
clearable
placeholder=""
:placeholder="t('settings.user.oldUsername')"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="原密码" prop="oldPwd" class="form_item">
<el-form-item :label="t('settings.user.oldPassword')" prop="oldPwd" class="form_item">
<el-input
v-model.trim="formData.oldPwd"
type="password"
clearable
show-password
placeholder=""
:placeholder="t('settings.user.oldPassword')"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="新用户名" prop="newLoginName" class="form_item">
<el-form-item :label="t('settings.user.newUsername')" prop="newLoginName" class="form_item">
<el-input
v-model.trim="formData.newLoginName"
clearable
placeholder=""
:placeholder="t('settings.user.newUsername')"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPwd" class="form_item">
<el-form-item :label="t('settings.user.newPassword')" prop="newPwd" class="form_item">
<el-input
v-model.trim="formData.newPwd"
type="password"
show-password
clearable
placeholder=""
:placeholder="t('settings.user.newPassword')"
autocomplete="off"
class="input"
@keyup.enter="handleUpdate"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleUpdate">确认</el-button>
<el-button type="primary" :loading="loading" @click="handleUpdate">{{ t('common.confirm') }}</el-button>
</el-form-item>
</el-form>
<h2 class="mfa2_title">两步验证(MFA2)</h2>
<h2 class="mfa2_title">{{ t('settings.user.mfaTitle') }}</h2>
<div v-if="isEnableMFA2">
<span class="enable_text">已启用</span>
<el-button class="disable_btn" type="danger" @click="handleDisableMFA2">禁用</el-button>
<span class="enable_text">{{ t('settings.user.enabled') }}</span>
<el-button class="disable_btn" type="danger" @click="handleDisableMFA2">{{ t('settings.user.disable') }}</el-button>
</div>
<template v-else>
<el-button v-if="startEnableMFA2" type="primary" @click="handleMFA2">启用</el-button>
<el-button v-if="startEnableMFA2" type="primary" @click="handleMFA2">{{ t('settings.user.enable') }}</el-button>
<template v-else>
<div class="mfa2_container">
<!-- https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2 -->
<p>1. 使用MFA2应用(<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" class="link">Google Authenticator</a>)扫描下面二维码,或者输入秘钥 <span class="secret">{{ MFA2Data.secret }}</span></p>
<p>
{{ t('settings.user.mfaStep1Prefix') }}
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" class="link">Google Authenticator</a>
{{ t('settings.user.mfaStep1Middle') }}
<span class="secret">{{ MFA2Data.secret }}</span>
</p>
<img :src="MFA2Data.qrImage" :alt="MFA2Data.secret">
<p>2. 输入MFA2应用上的6位数字</p>
<p>{{ t('settings.user.mfaStep2') }}</p>
<el-input
v-model="mfa2Token"
class="mfa2_input"
clearable
placeholder=""
:placeholder="t('settings.user.mfaTokenPlaceholder')"
autofocus
@keyup.enter="handleEnableMFA2"
/>
<el-button type="primary" @click="handleEnableMFA2">保存</el-button>
<el-button type="primary" @click="handleEnableMFA2">{{ t('common.save') }}</el-button>
</div>
</template>
</template>
@@ -83,9 +87,11 @@
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { RSAEncrypt } from '@utils/index.js'
const { proxy: { $api, $message, $messageBox, $store, $router } } = getCurrentInstance()
const { t } = useI18n()
const loading = ref(false)
const formRef = ref(null)
@@ -96,10 +102,10 @@ const formData = reactive({
newPwd: ''
})
const rules = reactive({
oldLoginName: { required: true, message: '输入原用户名', trigger: 'change' },
oldPwd: { required: true, message: '输入原密码', trigger: 'change' },
newLoginName: { required: true, message: '输入新用户名', trigger: 'change' },
newPwd: { required: true, message: '输入新密码', trigger: 'change' }
oldLoginName: { required: true, message: t('settings.user.validation.enterOldUsername'), trigger: 'change' },
oldPwd: { required: true, message: t('settings.user.validation.enterOldPassword'), trigger: 'change' },
newLoginName: { required: true, message: t('settings.user.validation.enterNewUsername'), trigger: 'change' },
newPwd: { required: true, message: t('settings.user.validation.enterNewPassword'), trigger: 'change' }
})
const startEnableMFA2 = ref(true)
@@ -114,11 +120,11 @@ const handleUpdate = () => {
formRef.value.validate()
.then(async () => {
$messageBox.confirm(
'修改用户名后会清除所有登录态,需重新登录',
'修改提示',
t('settings.user.changeLoginConfirm'),
t('settings.user.changeLoginTitle'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
showCancelButton: true,
cancelButtonClass: 'el-button--info',
@@ -136,7 +142,7 @@ const handleUpdate = () => {
formData.newPwd = ''
formRef.value.resetFields()
if (oldLoginName !== newLoginName) {
$message({ type: 'success', center: true, message: '用户名修改成功, 请重新登录' })
$message({ type: 'success', center: true, message: t('settings.user.usernameChangedRelogin') })
$store.removeLoginInfo()
$router.push('/login')
}
@@ -155,23 +161,23 @@ const handleMFA2 = async () => {
}
const handleEnableMFA2 = async () => {
if (!mfa2Token.value) return $message({ type: 'error', center: true, message: '请输入MFA2应用上的6位数字' })
if (!mfa2Token.value) return $message({ type: 'error', center: true, message: t('settings.user.enterMfaToken') })
let { msg } = await $api.enableMFA2({ token: mfa2Token.value })
$message({ type: 'success', center: true, message: msg })
getMFA2Status()
}
const handleDisableMFA2 = async () => {
$messageBox.prompt('请输入MFA2应用上的6位验证码来确认禁用', '禁用MFA2', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '请输入6位数字',
$messageBox.prompt(t('settings.user.disableMfaPrompt'), t('settings.user.disableMfaTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
inputPlaceholder: t('settings.user.mfaTokenPlaceholder'),
inputType: 'text',
inputPattern: /^\d{6}$/,
inputErrorMessage: '请输入6位数字',
inputErrorMessage: t('settings.user.invalidMfaToken'),
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
instance.confirmButtonText = '验证中...'
instance.confirmButtonText = t('settings.user.verifying')
$api.disableMFA2({ token: instance.inputValue })
.then(({ msg }) => {
$message({ type: 'success', center: true, message: msg })
@@ -182,7 +188,7 @@ const handleDisableMFA2 = async () => {
})
.finally(() => {
instance.confirmButtonLoading = false
instance.confirmButtonText = '确定'
instance.confirmButtonText = t('common.confirm')
})
} else {
done()
@@ -233,4 +239,4 @@ onMounted(() => {
.disable_btn {
margin: 0 15px;
}
</style>
</style>
+7 -5
View File
@@ -1,19 +1,19 @@
<template>
<div class="setting_container">
<el-tabs v-model="tabKey" tab-position="top">
<el-tab-pane label="修改密码" name="user">
<el-tab-pane :label="t('settings.changePassword')" name="user">
<User />
</el-tab-pane>
<el-tab-pane label="登录管理" name="session" lazy>
<el-tab-pane :label="t('settings.loginManagement')" name="session" lazy>
<Session />
</el-tab-pane>
<el-tab-pane label="通知配置" name="notify">
<el-tab-pane :label="t('settings.notificationConfig')" name="notify">
<Notify />
</el-tab-pane>
<el-tab-pane label="代理服务" name="proxy">
<el-tab-pane :label="t('settings.proxyService')" name="proxy">
<Proxy />
</el-tab-pane>
<el-tab-pane label="Plus激活" name="plus">
<el-tab-pane :label="t('settings.plusActivation')" name="plus">
<UserPlus />
</el-tab-pane>
</el-tabs>
@@ -22,6 +22,7 @@
<script setup>
import { watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import Session from './components/session.vue'
import User from './components/user.vue'
@@ -31,6 +32,7 @@ import Proxy from './components/proxy.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const tabKey = computed({
get() {
+55 -53
View File
@@ -12,14 +12,14 @@
class="select_all_checkbox"
@change="handleSelectAll"
>
全选
{{ t('docker.selectAll') }}
</el-checkbox>
<div v-if="selectedContainers.length > 0 || batchOperating" class="batch_info_tag">
<template v-if="batchOperating">
正在{{ batchProgress.action }}... <span class="progress">{{ batchProgress.current }}/{{ batchProgress.total }}</span>
{{ t('docker.batchInProgress', { action: batchProgress.action }) }} <span class="progress">{{ batchProgress.current }}/{{ batchProgress.total }}</span>
</template>
<template v-else>
已选中 <span class="count">{{ selectedContainers.length }}</span> 个
{{ t('docker.selectedCount', { count: selectedContainers.length }) }}
</template>
</div>
</div>
@@ -35,7 +35,7 @@
:disabled="batchOperating"
@click="handleBatchStart"
>
批量启动
{{ t('docker.batchStart') }}
</el-button>
<el-button
type="primary"
@@ -43,7 +43,7 @@
:disabled="batchOperating"
@click="handleBatchRestart"
>
重启
{{ t('docker.restart') }}
</el-button>
<el-button
type="warning"
@@ -51,7 +51,7 @@
:disabled="batchOperating"
@click="handleBatchStop"
>
停止
{{ t('docker.stop') }}
</el-button>
<el-button
type="danger"
@@ -59,14 +59,14 @@
:disabled="batchOperating"
@click="handleBatchDelete"
>
删除
{{ t('docker.delete') }}
</el-button>
</el-button-group>
</div>
</transition>
<div v-if="dockerServerErr" style="margin-right: 10px;">
<el-tag type="danger" effect="light" size="small">Docker服务连接失败</el-tag>
<el-tag type="danger" effect="light" size="small">{{ t('docker.serviceConnectFailed') }}</el-tag>
</div>
<el-button
type="primary"
@@ -75,14 +75,14 @@
:icon="RefreshRight"
@click="() => reconnectDocker(true)"
>
刷新
{{ t('docker.refresh') }}
</el-button>
</div>
</div>
<!-- 卡片列表区域 -->
<div v-loading="loading" class="card_container_wrapper">
<el-empty v-if="dockerContainers.length === 0 && !loading" description="暂无容器" />
<el-empty v-if="dockerContainers.length === 0 && !loading" :description="t('docker.empty')" />
<el-row :gutter="12">
<el-col
@@ -137,23 +137,23 @@
</div>
<div class="info_row">
<span class="label">镜像:</span>
<span class="label">{{ t('docker.image') }}:</span>
<span class="value text-truncate" :title="row.image">{{ row.image }}</span>
</div>
<div class="info_row">
<span class="label">时长:</span>
<span class="label">{{ t('docker.uptime') }}:</span>
<span class="value">{{ row.uptime }}</span>
</div>
<div class="info_row ports_row">
<span class="label">端口:</span>
<span class="label">{{ t('docker.ports') }}:</span>
<div class="ports_wrapper">
<template v-if="Array.isArray(row.ports) && row.ports.length > 0">
<template v-for="port in row.ports" :key="port">
<el-tooltip
v-if="!isPortMapped(port)"
content="此端口未映射到宿主机"
:content="t('docker.portNotMapped')"
placement="top"
>
<el-tag size="small" type="info" class="port_tag">{{ port || '--' }}</el-tag>
@@ -176,7 +176,7 @@
<!-- 卡片底部:操作按钮 -->
<div class="card_actions">
<el-tooltip content="启动" placement="top" :hide-after="0">
<el-tooltip :content="t('docker.start')" placement="top" :hide-after="0">
<el-button
type="success"
:icon="VideoPlay"
@@ -188,7 +188,7 @@
/>
</el-tooltip>
<el-tooltip content="重启" placement="top" :hide-after="0">
<el-tooltip :content="t('docker.restart')" placement="top" :hide-after="0">
<el-button
type="primary"
:icon="RefreshRight"
@@ -200,7 +200,7 @@
/>
</el-tooltip>
<el-tooltip content="停止" placement="top" :hide-after="0">
<el-tooltip :content="t('docker.stop')" placement="top" :hide-after="0">
<el-button
type="warning"
:icon="VideoPause"
@@ -212,7 +212,7 @@
/>
</el-tooltip>
<el-tooltip content="删除" placement="top" :hide-after="0">
<el-tooltip :content="t('docker.delete')" placement="top" :hide-after="0">
<el-button
type="danger"
:icon="Delete"
@@ -223,7 +223,7 @@
/>
</el-tooltip>
<el-tooltip content="日志" placement="top" :hide-after="0">
<el-tooltip :content="t('docker.logs')" placement="top" :hide-after="0">
<el-button
type="info"
:icon="Document"
@@ -251,6 +251,7 @@
<script setup>
import { ref, onMounted, onUnmounted, computed, watch, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { VideoPlay, VideoPause, RefreshRight, Delete, Document } from '@element-plus/icons-vue'
import LogViewer from '@/components/log-viewer/index.vue'
import PlusLimitTip from '@/components/common/PlusLimitTip.vue'
@@ -258,6 +259,7 @@ import { generateSocketInstance } from '@/utils'
import dayjs from 'dayjs'
const { proxy: { $store, $message, $messageBox } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
hostId: {
@@ -287,7 +289,7 @@ const selectedContainers = ref([])
const batchOperating = ref(false)
const batchProgress = ref({ current: 0, total: 0, action: '' }) // 批量操作进度
const hostId = computed(() => props.hostId)
const containerName = computed(() => `${ currentContainer.value?.name }.log(自动刷新)` || '')
const containerName = computed(() => `${ currentContainer.value?.name }.log` || '')
const isPlusActive = computed(() => $store.isPlusActive)
// 全选相关计算属性
@@ -363,7 +365,7 @@ const getStatusType = (status) => {
}
const connectDocker = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('docker.plusOnly'))
socket.value = generateSocketInstance('/docker')
socket.value.on('connect', () => {
console.log('/docker socket已连接:', hostId.value)
@@ -452,7 +454,7 @@ const reconnectDocker = () => {
const handleStart = async (row) => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -462,7 +464,7 @@ const handleStart = async (row) => {
const handleStop = async (row) => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -472,7 +474,7 @@ const handleStop = async (row) => {
const handleRestart = async (row) => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -481,13 +483,13 @@ const handleRestart = async (row) => {
}
const handleDelete = (row) => {
$messageBox.confirm(`确认删除容器 ${ row.name }?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('docker.deleteConfirm', { name: row.name }), t('docker.warningTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(async () => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -510,13 +512,13 @@ const handlePortClick = (portStr) => {
const url = `http://${ props.host }:${ port }`
window.open(url, '_blank')
} else {
$message.warning('无法解析端口信息')
$message.warning(t('docker.parsePortFailed'))
}
}
const handleLogs = (row) => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -529,7 +531,7 @@ const handleLogs = (row) => {
const intervalLogs = () => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
@@ -565,13 +567,13 @@ const handleSelectAll = (val) => {
// 批量操作 - 串行执行
const executeBatchOperation = async (operation, containers, actionName) => {
if (!socket.value || !socket.value.connected) {
$message.error('连接已断开,正在刷新')
$message.error(t('docker.disconnectedRefreshing'))
refreshDockerContainers(true, 0)
return
}
if (batchOperating.value) {
$message.warning('批量操作正在进行中,请稍候')
$message.warning(t('docker.batchOperatingWait'))
return
}
@@ -597,7 +599,7 @@ const executeBatchOperation = async (operation, containers, actionName) => {
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('操作超时(30s)'))
reject(new Error(t('docker.operationTimeout')))
}, 30 * 1000)
const handleResult = (result) => {
@@ -638,11 +640,11 @@ const executeBatchOperation = async (operation, containers, actionName) => {
// 显示结果
if (failCount === 0) {
$message.success(`批量${ actionName }完成:全部成功(${ successCount }/${ containers.length })`)
$message.success(t('docker.batchResultSuccess', { action: actionName, success: successCount, total: containers.length }))
} else {
const failedNames = failedContainers.slice(0, 3).join('、')
const moreText = failedContainers.length > 3 ? ` 等${ failedContainers.length }个` : ''
$message.warning(`批量${ actionName }完成:成功 ${ successCount } 个,失败 ${ failCount } 个(${ failedNames }${ moreText })`)
const moreText = failedContainers.length > 3 ? t('docker.moreCount', { count: failedContainers.length }) : ''
$message.warning(t('docker.batchResultPartial', { action: actionName, success: successCount, failed: failCount, names: failedNames, moreText }))
}
// 刷新列表
@@ -653,12 +655,12 @@ const executeBatchOperation = async (operation, containers, actionName) => {
const handleBatchStart = () => {
const canStart = selectedContainers.value.filter(c => c.status !== 'running')
if (canStart.length === 0) {
$message.warning('没有可启动的容器(已选容器都在运行中)')
$message.warning(t('docker.noStartableContainers'))
return
}
$messageBox.confirm(`确认批量启动 ${ canStart.length } 个容器?`, '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('docker.confirmBatchStart', { count: canStart.length }), t('docker.confirmTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'info'
}).then(() => {
executeBatchOperation('docker_start_container', canStart, '启动')
@@ -669,12 +671,12 @@ const handleBatchStart = () => {
const handleBatchRestart = () => {
const canRestart = selectedContainers.value.filter(c => c.status === 'running')
if (canRestart.length === 0) {
$message.warning('没有可重启的容器(已选容器都未运行)')
$message.warning(t('docker.noRestartableContainers'))
return
}
$messageBox.confirm(`确认批量重启 ${ canRestart.length } 个容器?`, '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('docker.confirmBatchRestart', { count: canRestart.length }), t('docker.confirmTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(() => {
executeBatchOperation('docker_restart_container', canRestart, '重启')
@@ -685,12 +687,12 @@ const handleBatchRestart = () => {
const handleBatchStop = () => {
const canStop = selectedContainers.value.filter(c => c.status === 'running')
if (canStop.length === 0) {
$message.warning('没有可停止的容器(已选容器都未运行)')
$message.warning(t('docker.noStoppableContainers'))
return
}
$messageBox.confirm(`确认批量停止 ${ canStop.length } 个容器?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('docker.confirmBatchStop', { count: canStop.length }), t('docker.warningTitle'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}).then(() => {
executeBatchOperation('docker_stop_container', canStop, '停止')
@@ -700,11 +702,11 @@ const handleBatchStop = () => {
// 批量删除
const handleBatchDelete = () => {
$messageBox.confirm(
`确认批量删除 ${ selectedContainers.value.length } 个容器?此操作不可恢复!`,
'危险操作',
t('docker.confirmBatchDelete', { count: selectedContainers.value.length }),
t('docker.dangerTitle'),
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
confirmButtonText: t('docker.confirmDelete'),
cancelButtonText: t('common.cancel'),
type: 'error'
}
).then(() => {
@@ -1,7 +1,7 @@
<template>
<el-drawer
v-model="visible"
title="菜单选项"
:title="t('terminal.menuOptions')"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
@@ -15,18 +15,18 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="实例" prop="instanceGroup">
<el-form-item :label="t('menu.serverList')" 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-button :value="true">{{ t('terminal.groupCascader') }}</el-radio-button>
<el-radio-button :value="false">{{ t('terminal.flatList') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="脚本库" prop="scriptLibrary">
<el-form-item :label="t('menu.scripts')" prop="scriptLibrary">
<el-radio-group
v-model="scriptLibraryCascader"
size="small"
@@ -34,8 +34,8 @@
:fill="scriptLibrary ? '#13ce66' : '#999'"
:disabled="!scriptLibrary"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
<el-radio-button :value="true">{{ t('terminal.groupCascader') }}</el-radio-button>
<el-radio-button :value="false">{{ t('terminal.flatList') }}</el-radio-button>
</el-radio-group>
<span class="script_library_switch">
<el-switch
@@ -43,15 +43,15 @@
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999"
active-text="显示"
inactive-text="隐藏"
:active-text="t('terminal.show')"
:inactive-text="t('terminal.hide')"
/>
</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
<el-button @click="visible = false">{{ t('common.close') }}</el-button>
</span>
</template>
</el-drawer>
@@ -59,10 +59,12 @@
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import useMobileWidth from '@/composables/useMobileWidth'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -1,7 +1,7 @@
<template>
<el-drawer
v-model="visible"
title="其他设置"
:title="t('terminal.otherSettings')"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
@@ -15,22 +15,22 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="自动重连" prop="autoReconnect">
<el-form-item :label="t('terminal.autoReconnect')" prop="autoReconnect">
<span>
<el-switch
v-model="autoReconnect"
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
:active-text="t('terminal.enabled')"
:inactive-text="t('terminal.disabled')"
/>
</span>
</el-form-item>
<el-form-item label="脚本执行" prop="autoExecuteScript">
<el-form-item :label="t('terminal.scriptExecution')" prop="autoExecuteScript">
<el-tooltip
effect="dark"
content="启用后从脚本库选中脚本后自动执行(回车操作)"
:content="t('terminal.scriptExecutionTip')"
placement="right"
>
<el-switch
@@ -38,15 +38,15 @@
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="自动"
inactive-text="手动"
:active-text="t('terminal.auto')"
:inactive-text="t('terminal.manual')"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="右键菜单" prop="autoShowContextMenu">
<el-form-item :label="t('terminal.contextMenu')" prop="autoShowContextMenu">
<el-tooltip
effect="dark"
content="启用后选中文本时自动弹出右键菜单"
:content="t('terminal.contextMenuTip')"
placement="right"
>
<el-switch
@@ -54,15 +54,15 @@
class="switch"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="自动"
inactive-text="手动"
:active-text="t('terminal.auto')"
:inactive-text="t('terminal.manual')"
/>
</el-tooltip>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
<el-button @click="visible = false">{{ t('common.close') }}</el-button>
</span>
</template>
</el-drawer>
@@ -70,10 +70,12 @@
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import useMobileWidth from '@/composables/useMobileWidth'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -2,7 +2,7 @@
<Teleport :to="teleportTarget">
<el-dialog
v-model="dialogVisible"
title="编辑高亮规则"
:title="t('terminal.editHighlightRule')"
width="900px"
:append-to-body="false"
:close-on-click-modal="false"
@@ -15,20 +15,20 @@
:rules="formRules"
label-width="120px"
>
<el-form-item label="规则标题" prop="title">
<el-form-item :label="t('terminal.ruleTitle')" prop="title">
<el-input
v-model="formData.title"
placeholder="留空则显示默认名称"
:placeholder="t('terminal.emptyUseDefaultName')"
maxlength="10"
show-word-limit
clearable
/>
</el-form-item>
<el-form-item label="颜色设置">
<el-form-item :label="t('terminal.colorSettings')">
<div class="color-row">
<div class="color-item">
<label>文本颜色</label>
<label>{{ t('terminal.textColor') }}</label>
<el-color-picker
v-model="formData.displayColor"
size="small"
@@ -38,7 +38,7 @@
/>
</div>
<div class="color-item">
<label>背景颜色</label>
<label>{{ t('terminal.backgroundColor') }}</label>
<el-color-picker
v-model="formData.backgroundColor"
size="small"
@@ -51,39 +51,39 @@
</div>
</el-form-item>
<el-form-item label="文字样式">
<el-form-item :label="t('terminal.textStyle')">
<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 value="bold">{{ t('terminal.boldText') }}</el-checkbox>
<el-checkbox value="italic">{{ t('terminal.italic') }}</el-checkbox>
<el-checkbox value="underline">{{ t('terminal.underline') }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="正则表达式" prop="pattern">
<el-form-item :label="t('terminal.regexExpression')" prop="pattern">
<el-input
v-model="patternSource"
type="textarea"
:rows="3"
placeholder="请输入正则表达式"
:placeholder="t('terminal.inputRegexExpression')"
@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 :label="t('terminal.ruleOptions')">
<el-checkbox v-model="ignoreCase" @change="updatePattern">{{ t('terminal.ignoreCase') }}</el-checkbox>
<el-checkbox v-model="fullLine">{{ t('terminal.highlightWholeLine') }}</el-checkbox>
</el-form-item>
<el-form-item label="测试文本">
<el-form-item :label="t('terminal.testText')">
<el-input
v-model="testInput"
type="textarea"
:rows="2"
placeholder="输入测试文本"
:placeholder="t('terminal.inputTestText')"
/>
</el-form-item>
<el-form-item label="匹配结果">
<el-form-item :label="t('terminal.matchResult')">
<div class="test-result">
<div v-if="testMatches.length" class="matches">
<el-tag
@@ -97,16 +97,16 @@
</el-tag>
</div>
<el-text v-else size="small" type="info">
无匹配结果
{{ t('terminal.noMatchResult') }}
</el-text>
</div>
</el-form-item>
<el-form-item label="预览效果">
<el-form-item :label="t('terminal.preview')">
<div class="preview-result" v-html="previewHtml" />
</el-form-item>
<el-form-item label="常用模板">
<el-form-item :label="t('terminal.commonTemplates')">
<div class="templates">
<el-button
v-for="template in getTemplates(ruleName)"
@@ -123,8 +123,8 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="handleCancel">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSave">{{ t('common.save') }}</el-button>
</div>
</template>
</el-dialog>
@@ -133,9 +133,11 @@
<script setup>
import { ref, computed, watch, getCurrentInstance, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { HIGHLIGHT_RULES, TerminalHighlighter } from '@/utils/highlighter'
const { proxy: { $message, $messageBox } } = getCurrentInstance()
const { t } = useI18n()
// 全屏时teleport到fullscreenElement,否则teleport到body
const teleportTarget = computed(() => {
@@ -207,20 +209,20 @@ const formData = ref({
const formRules = {
title: [
{ max: 10, message: '标题长度最多 10 个字符', trigger: 'blur' },
{ max: 10, message: t('terminal.titleMaxLength'), trigger: 'blur' },
],
pattern: [
{
required: true,
validator: (_rule, _value, callback) => {
if (!patternSource.value || patternSource.value.trim() === '') {
callback(new Error('请输入正则表达式'))
callback(new Error(t('terminal.inputRegexRequired')))
} else {
try {
new RegExp(patternSource.value, patternFlags.value)
callback()
} catch (error) {
callback(new Error('正则表达式格式错误'))
callback(new Error(t('terminal.regexInvalid')))
}
}
},
@@ -246,7 +248,7 @@ const testMatches = computed(() => {
const previewHtml = computed(() => {
if (!patternSource.value || !testInput.value) {
return testInput.value || '请输入测试文本'
return testInput.value || t('terminal.inputPreviewText')
}
try {
@@ -266,7 +268,7 @@ const previewHtml = computed(() => {
// 使用highlighter的HTML预览方法
return previewHighlighter.applySingleRuleForHtml(testInput.value, currentRule)
} catch (error) {
return `正则表达式错误: ${ error.message }`
return t('terminal.regexError', { message: error.message })
}
})
@@ -340,7 +342,7 @@ const handleColorChange = (newColor) => {
// 检查颜色是否重复
if (isColorUsed(newColor, props.allRules, props.ruleName)) {
$message.warning('该颜色已被其他规则使用,请选择其他颜色')
$message.warning(t('terminal.colorAlreadyUsed'))
// 阻止更新,强制恢复原颜色
const originalColor = props.ruleData?.displayColor || HIGHLIGHT_RULES[props.ruleName].displayColor
// 使用nextTick确保在下一个事件循环中恢复颜色
@@ -373,7 +375,7 @@ const getTemplates = (ruleName) => {
const templates = [
// 第一个模板:真实的默认规则
{
name: '默认规则(完整)',
name: t('terminal.defaultRuleFull'),
pattern: defaultRule.pattern.source,
flags: defaultRule.flags.split('')
},
@@ -645,7 +647,7 @@ const updatePattern = () => {
try {
formData.value.pattern = new RegExp(patternSource.value, patternFlags.value)
} catch (error) {
console.error('正则表达式无效:', error)
console.error(t('terminal.invalidRegex'), error)
}
}
@@ -663,12 +665,12 @@ const handleSave = async () => {
updatePattern()
if (!formData.value.pattern) {
throw new Error('正则表达式无效')
throw new Error(t('terminal.invalidRegex'))
}
// 最终检查颜色是否重复
if (isColorUsed(formData.value.displayColor, props.allRules, props.ruleName)) {
$message.error('该颜色已被其他规则使用,无法保存')
$message.error(t('terminal.colorAlreadyUsedCannotSave'))
return
}
@@ -714,12 +716,12 @@ const handleBeforeClose = async (done) => {
if (hasDataChanged()) {
try {
await $messageBox.confirm(
'您有未保存的修改,确定要关闭吗?',
'确认关闭',
t('terminal.unsavedChangesCloseConfirm'),
t('terminal.closeConfirmTitle'),
{
type: 'warning',
confirmButtonText: '确定关闭',
cancelButtonText: '继续编辑'
confirmButtonText: t('terminal.confirmClose'),
cancelButtonText: t('terminal.continueEditing')
}
)
done()
@@ -16,7 +16,7 @@
@click="handleShowGroupManage"
>
<el-icon><Setting /></el-icon>
分组管理
{{ t('terminal.groupManagement') }}
</div>
</div>
<div class="script_list">
@@ -33,7 +33,7 @@
<div class="hover_icons">
<el-icon
class="action_icon"
title="执行"
:title="t('terminal.execute')"
@click="handleExecScript(script)"
>
<VideoPlay />
@@ -41,14 +41,14 @@
<el-icon
v-if="activeGroup !== 'builtin'"
class="action_icon"
title="编辑脚本"
:title="t('terminal.editScript')"
@click="handleEditScript(script)"
>
<Edit />
</el-icon>
<el-icon
class="action_icon"
title="发送到脚本编辑器"
:title="t('terminal.sendToScriptEditor')"
@click="handleSendToEditor(script)"
>
<ArrowRightBold />
@@ -61,39 +61,38 @@
@click="handleAddScript"
>
<el-icon><Plus /></el-icon>
<span>新建脚本</span>
<span>{{ t('terminal.createScript') }}</span>
</div>
</template>
<div v-else class="empty-list">
<el-empty
:image-size="38"
description="暂无脚本"
:description="t('terminal.noScripts')"
>
<template #description>
<p>当前分组暂无脚本</p>
<p>{{ t('terminal.noScriptsInGroup') }}</p>
</template>
<el-button
size="small"
type="primary"
@click="handleAddScript"
>
添加脚本
{{ t('terminal.addScript') }}
</el-button>
</el-empty>
</div>
</div>
</div>
<div class="right_box">
<div class="editor_title">脚本编辑器</div>
<div class="editor_title">{{ t('terminal.scriptEditor') }}</div>
<div class="editor_content">
<el-input
v-model="scriptContent"
type="textarea"
:rows="10"
placeholder="请输入脚本内容..."
:placeholder="t('terminal.inputScriptContent')"
/>
<!-- 脚本编辑器中的脚本编码模式选项 -->
<div class="execution_mode_selector">
<el-select
v-model="editorUseBase64"
@@ -101,8 +100,8 @@
style="width: 100px;"
:teleported="false"
>
<el-option :value="false" label="直接发送" />
<el-option :value="true" label="Base64编码" />
<el-option :value="false" :label="t('terminal.directSend')" />
<el-option :value="true" :label="t('terminal.base64Encoding')" />
</el-select>
</div>
<div class="action_btn">
@@ -117,13 +116,11 @@
>
<template #dropdown>
<el-dropdown-menu>
<!-- <el-dropdown-item>发送至当前窗口终端</el-dropdown-item> -->
<!-- <el-dropdown-item>发送至所有窗口终端</el-dropdown-item> -->
<el-dropdown-item @click="handleSaveAsScript">保存至脚本库</el-dropdown-item>
<el-dropdown-item @click="handleSendClearContent">发送后清空内容: {{ isClearContent ? '是' : '否' }}</el-dropdown-item>
<el-dropdown-item @click="handleSaveAsScript">{{ t('terminal.saveToScriptLibrary') }}</el-dropdown-item>
<el-dropdown-item @click="handleSendClearContent">{{ t('terminal.clearAfterSend') }}: {{ isClearContent ? t('terminal.yes') : t('terminal.no') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
发送至终端
{{ t('terminal.sendToTerminal') }}
</el-dropdown>
</div>
</div>
@@ -145,6 +142,7 @@
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { VideoPlay, Edit, Setting, Plus, ArrowRightBold } from '@element-plus/icons-vue'
import ScriptEdit from '@/views/scripts/components/script-edit.vue'
@@ -161,6 +159,7 @@ defineProps({
const emit = defineEmits(['exec-command',])
const { proxy: { $store, $message } } = getCurrentInstance()
const { t } = useI18n()
const router = useRouter()
const scriptContent = ref('')
@@ -184,28 +183,28 @@ const filteredScripts = computed(() => {
})
const handleSelectGroup = (group) => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
activeGroup.value = group.id
}
const handleExecScript = (script) => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
emit('exec-command', { command: script.command, useBase64: script.useBase64 || false })
}
const handleEditScript = (script) => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
editScriptData.value = script
scriptEditVisible.value = true
}
const handleSendToEditor = (script) => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
scriptContent.value = script.command
}
const handleSendToTerminal = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
emit('exec-command', { command: scriptContent.value, useBase64: editorUseBase64.value })
if (isClearContent.value) {
scriptContent.value = ''
@@ -217,23 +216,23 @@ const handleSendClearContent = () => {
}
const handleAddScript = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
editScriptData.value = null
scriptEditVisible.value = true
}
const handleScriptEditSuccess = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
editScriptData.value = null
}
const handleShowGroupManage = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
scriptGroupVisible.value = true
}
const handleGroupDeleted = (deletedGroupId) => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
if (deletedGroupId === activeGroup.value) {
// 如果删除的是当前选中的分组,切换到第一个分组
if (scriptGroupList.value.length) {
@@ -243,9 +242,9 @@ const handleGroupDeleted = (deletedGroupId) => {
}
const handleSaveAsScript = () => {
if (!isPlusActive.value) return $message.warning('此功能仅限PLUS版使用')
if (!isPlusActive.value) return $message.warning(t('terminal.plusOnlyFeature'))
if (!scriptContent.value) {
return $message.warning('请先输入脚本内容')
return $message.warning(t('terminal.inputScriptFirst'))
}
scriptEditVisible.value = true
}
@@ -1,6 +1,5 @@
<template>
<div class="info_container" :style="{ width: visible ? `250px` : 0 }">
<!-- <el-divider class="first-divider" content-position="center">地理位置</el-divider> -->
<el-descriptions
class="margin-top"
:column="1"
@@ -10,22 +9,22 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
主机
{{ t('terminal.host') }}
</div>
</template>
<span class="host-info-ip" :title="host">{{ host }}</span>
<template v-if="pingMs">
<el-tooltip effect="dark" content="该值为EasyNode服务端主机到目标主机的ping值" placement="bottom">
<el-tooltip effect="dark" :content="t('terminal.pingDescription')" placement="bottom">
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
</el-tooltip>
</template>
<el-tag size="small" style="cursor: pointer;margin-left: 10px;" @click="handleCopy">复制</el-tag>
<el-tag size="small" style="cursor: pointer;margin-left: 10px;" @click="handleCopy">{{ t('common.copy') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="item_title">
在线
{{ t('terminal.uptime') }}
</div>
</template>
<div size="small">
@@ -34,7 +33,6 @@
</el-descriptions-item>
</el-descriptions>
<!-- <el-divider content-position="center">实时监控</el-divider> -->
<br>
<el-descriptions
@@ -46,7 +44,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
CPU
{{ t('common.cpu') }}
</div>
</template>
<el-progress
@@ -59,10 +57,10 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
负载
{{ t('terminal.load') }}
</div>
</template>
<el-tooltip effect="dark" content="过去1分钟、5分钟、15分钟CPU负载" placement="bottom">
<el-tooltip effect="dark" :content="t('terminal.loadDescription')" placement="bottom">
<div class="load-avg-display">
<span
v-for="(load, index) in loadAvgFormatted"
@@ -77,7 +75,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
内存
{{ t('common.memory') }}
</div>
</template>
<el-progress
@@ -93,7 +91,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
交换
{{ t('terminal.swap') }}
</div>
</template>
<el-progress
@@ -109,13 +107,13 @@
<el-descriptions-item v-for="(d,idx) in drivesInfo" :key="d.filesystem + idx">
<template #label>
<div class="item_title">
{{ drivesInfo.length > 1 ? `硬盘(${idx+1})` : '硬盘' }}
{{ drivesInfo.length > 1 ? t('terminal.diskWithIndex', { index: idx + 1 }) : t('terminal.disk') }}
</div>
</template>
<el-tooltip
effect="dark"
placement="top"
:content="`文件系统:${d.filesystem} 挂载点:${d.mountedOn}`"
:content="t('terminal.filesystemMountPoint', { filesystem: d.filesystem, mountedOn: d.mountedOn })"
>
<el-progress
:text-inside="true"
@@ -139,7 +137,7 @@
<el-descriptions-item key="netstat_item">
<template #label>
<div class="item_title">
网络
{{ t('common.network') }}
</div>
</template>
<div class="netstat_info">
@@ -160,7 +158,6 @@
</el-descriptions-item>
</el-descriptions>
<!-- <el-divider content-position="center">系统信息</el-divider> -->
<br>
<el-descriptions
@@ -172,7 +169,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
名称
{{ t('common.name') }}
</div>
</template>
<div size="small">
@@ -182,7 +179,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
核心
{{ t('terminal.cores') }}
</div>
</template>
<div size="small">
@@ -202,7 +199,7 @@
<el-descriptions-item>
<template #label>
<div class="item_title">
类型
{{ t('common.type') }}
</div>
</template>
<div size="small">
@@ -215,14 +212,15 @@
<script setup>
import { ref, computed, getCurrentInstance, watch, onBeforeUnmount, nextTick, onMounted, toRaw, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart, registerables } from 'chart.js'
import { generateSocketInstance } from '@/utils'
import clipboard from '@/utils/clipboard'
// 注册Chart.js所有组件
Chart.register(...registerables)
const { proxy: { $message, $tools, $store } } = getCurrentInstance()
const { t, locale } = useI18n()
const props = defineProps({
hostId: {
@@ -633,7 +631,7 @@ const initNetworkChart = () => {
labels: [], // 时间标签
datasets: [
{
label: '上传',
label: locale.value === 'en' ? 'Upload' : '上传',
data: [],
borderColor: '#CF8A20',
backgroundColor: 'rgba(207, 138, 32, 0.1)',
@@ -644,7 +642,7 @@ const initNetworkChart = () => {
pointHoverRadius: 3
},
{
label: '下载',
label: locale.value === 'en' ? 'Download' : '下载',
data: [],
borderColor: '#67c23a',
backgroundColor: 'rgba(103, 194, 58, 0.1)',
@@ -721,7 +719,7 @@ const initNetworkChart = () => {
// 更新网速历史数据
const updateNetworkHistory = (uploadMb, downloadMb) => {
const now = new Date()
const timeLabel = now.toLocaleTimeString('zh-CN', {
const timeLabel = now.toLocaleTimeString(locale.value === 'en' ? 'en-US' : 'zh-CN', {
hour12: false,
minute: '2-digit',
second: '2-digit'
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
<template>
<el-drawer
v-model="dialogVisible"
title="高亮设置"
:title="t('terminal.highlightSettings')"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
@@ -15,49 +15,47 @@
label-width="130px"
:show-message="false"
>
<el-form-item label="关键词高亮">
<el-form-item :label="t('terminal.keywordHighlight')">
<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="关闭"
:active-text="t('terminal.enabled')"
:inactive-text="t('terminal.disabled')"
@change="handleConfigChange"
/>
</el-form-item>
<el-form-item v-if="highlightConfig.enabled" label="调试模式">
<el-form-item v-if="highlightConfig.enabled" :label="t('terminal.debugMode')">
<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="关闭"
:active-text="t('terminal.enabled')"
:inactive-text="t('terminal.disabled')"
@change="handleConfigChange"
/>
<el-text class="debug-tip" size="small" type="info">
开启后会在控制台显示详细的高亮处理信息
{{ t('terminal.debugModeTip') }}
</el-text>
</el-form-item>
<!-- 规则管理按钮 -->
<el-form-item v-if="highlightConfig.enabled" label="规则管理">
<el-form-item v-if="highlightConfig.enabled" :label="t('terminal.ruleManagement')">
<div class="rule-actions">
<el-button type="warning" size="small" @click="handleRestoreDefaults">
恢复默认
{{ t('common.restoreDefault') }}
</el-button>
<el-button type="primary" size="small" @click="handleExportRules">
导出规则
{{ t('terminal.exportRules') }}
</el-button>
<el-button type="success" size="small" @click="handleImportRules">
导入规则
{{ t('terminal.importRules') }}
</el-button>
</div>
</el-form-item>
<!-- 高亮规则列表 -->
<div v-if="highlightConfig.enabled" class="rules-section">
<div
v-for="(rule, ruleName) in highlightRules"
@@ -67,41 +65,37 @@
<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="禁用"
:active-text="t('terminal.enableRule')"
:inactive-text="t('terminal.disableRule')"
@change="handleRuleEnabledChange(ruleName, rule.enabled)"
/>
</div>
<div class="color-info">
<!-- 文本颜色预览 -->
<div class="color-display">
<span class="color-label">文本</span>
<span class="color-label">{{ t('terminal.textColor') }}</span>
<span
class="color-preview"
:style="{ backgroundColor: getColorDisplay(rule) }"
:title="`文本颜色: ${getColorDisplay(rule)}`"
:title="t('terminal.textColorTitle', { color: getColorDisplay(rule) })"
/>
</div>
<!-- 背景色预览 -->
<div v-if="rule.backgroundColor" class="color-display">
<span class="color-label">背景</span>
<span class="color-label">{{ t('terminal.backgroundColor') }}</span>
<span
class="background-preview"
:style="{ backgroundColor: rule.backgroundColor }"
:title="`背景色: ${rule.backgroundColor}`"
:title="t('terminal.backgroundColorTitle', { color: rule.backgroundColor })"
/>
</div>
<!-- 样式标签 -->
<div class="style-tags">
<el-tag
v-if="rule.bold"
@@ -109,7 +103,7 @@
type="danger"
effect="dark"
>
粗体
{{ t('terminal.bold') }}
</el-tag>
<el-tag
v-if="rule.italic"
@@ -117,7 +111,7 @@
type="warning"
effect="dark"
>
斜体
{{ t('terminal.italic') }}
</el-tag>
<el-tag
v-if="rule.underline"
@@ -125,7 +119,7 @@
type="info"
effect="dark"
>
下划线
{{ t('terminal.underline') }}
</el-tag>
</div>
</div>
@@ -138,7 +132,7 @@
:disabled="!rule.enabled"
@click="editRule(ruleName)"
>
编辑
{{ t('terminal.editRule') }}
</el-button>
</div>
</div>
@@ -147,20 +141,18 @@
</div>
</div>
<!-- 预览效果 -->
<el-form-item v-if="highlightConfig.enabled" label="预览效果">
<el-form-item v-if="highlightConfig.enabled" :label="t('terminal.preview')">
<div class="test-panel">
<el-input
v-model="testText"
type="textarea"
:rows="2"
placeholder="输入测试文本,查看高亮效果"
:placeholder="t('terminal.previewPlaceholder')"
class="test-input"
/>
</div>
</el-form-item>
<!-- 预览输出 -->
<div
v-if="highlightConfig.enabled"
class="test-output"
@@ -171,11 +163,10 @@
<template #footer>
<span class="dialog_footer">
<el-button @click="handleCancel">关闭</el-button>
<el-button @click="handleCancel">{{ t('common.close') }}</el-button>
</span>
</template>
<!-- 规则编辑对话框 -->
<RuleEditDialog
v-model:show="showRuleEdit"
:rule-name="editingRuleName"
@@ -184,21 +175,20 @@
@save="handleRuleSave"
/>
<!-- 导入对话框 -->
<el-dialog
v-model="showImportDialog"
title="导入高亮规则"
:title="t('terminal.importHighlightRules')"
width="600px"
>
<el-input
v-model="importRulesText"
type="textarea"
:rows="10"
placeholder="请粘贴规则JSON内容"
:placeholder="t('terminal.importRulesPlaceholder')"
/>
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="confirmImportRules">确认导入</el-button>
<el-button @click="showImportDialog = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="confirmImportRules">{{ t('terminal.confirmImportRules') }}</el-button>
</template>
</el-dialog>
</el-drawer>
@@ -206,6 +196,7 @@
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { HIGHLIGHT_RULES } from '@/utils/highlighter'
import useMobileWidth from '@/composables/useMobileWidth'
import RuleEditDialog from './rule-edit-dialog.vue'
@@ -213,6 +204,7 @@ import themeList from 'xterm-theme'
const { proxy: { $store, $message, $messageBox } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -427,7 +419,7 @@ const highlightedTestText = computed(() => {
return finalOutput
} catch (error) {
console.error('预览错误:', error)
console.error('Preview error:', error)
return testText.value.replace(/\n/g, '<br>')
}
})
@@ -449,7 +441,7 @@ const loadConfig = () => {
for (const [key, rule,] of Object.entries(terminalConfig.customHighlightRules)) {
// 跳过无效的规则
if (!rule.pattern || (typeof rule.pattern === 'object' && !rule.pattern.source)) {
console.warn(`跳过无效的自定义规则 ${ key }:`, rule.pattern)
console.warn(`Skipping invalid custom rule ${ key }:`, rule.pattern)
continue
}
@@ -459,7 +451,7 @@ const loadConfig = () => {
} else if (rule.pattern.source) {
validPattern = new RegExp(rule.pattern.source, rule.flags || 'gi')
} else {
console.warn(`跳过无法解析的规则 ${ key }:`, rule.pattern)
console.warn(`Skipping unparsable rule ${ key }:`, rule.pattern)
continue
}
@@ -505,9 +497,12 @@ const handleRuleEnabledChange = async (ruleName, enabled) => {
}
await $store.setTerminalSetting(config)
$message.success(`${ getRuleTitle(ruleName) }${ enabled ? '已启用' : '已禁用' }`)
$message.success(t('terminal.ruleEnabledStatus', {
name: getRuleTitle(ruleName),
status: enabled ? t('terminal.enabledSuffix') : t('terminal.disabledSuffix')
}))
} catch (error) {
$message.error('保存设置失败')
$message.error(t('terminal.saveSettingsFailed'))
// 回滚状态
highlightRules.value[ruleName].enabled = !enabled
}
@@ -525,16 +520,16 @@ const handleRuleSave = async (ruleName, ruleData) => {
}
await $store.setTerminalSetting(config)
$message.success('规则保存成功')
$message.success(t('terminal.ruleSaved'))
} catch (error) {
$message.error('保存规则失败')
$message.error(t('terminal.saveRuleFailed'))
}
}
// 恢复默认规则
const handleRestoreDefaults = async () => {
try {
await $messageBox.confirm('确认恢复为默认高亮规则?此操作将覆盖所有自定义规则和颜色。', '确认', {
await $messageBox.confirm(t('terminal.restoreDefaultHighlightRulesConfirm'), t('common.confirm'), {
type: 'warning'
})
@@ -547,10 +542,10 @@ const handleRestoreDefaults = async () => {
}
await $store.setTerminalSetting(config)
$message.success('已恢复默认规则和颜色')
$message.success(t('terminal.restoredDefaultRules'))
} catch (error) {
if (error !== 'cancel') {
$message.error('恢复默认规则失败')
$message.error(t('terminal.restoreDefaultRulesFailed'))
}
}
}
@@ -571,7 +566,7 @@ const handleExportRules = () => {
a.click()
URL.revokeObjectURL(url)
$message.success('规则导出成功')
$message.success(t('terminal.ruleExportSuccess'))
}
// 导入规则
@@ -612,9 +607,9 @@ const confirmImportRules = async () => {
await $store.setTerminalSetting(config)
showImportDialog.value = false
$message.success('规则导入并保存成功')
$message.success(t('terminal.ruleImportSuccess'))
} catch (error) {
$message.error('导入失败,请检查JSON格式')
$message.error(t('terminal.importRulesFailed'))
}
}
@@ -633,7 +628,7 @@ const handleConfigChange = async () => {
await $store.setTerminalSetting(config)
} catch (error) {
$message.error('保存配置失败')
$message.error(t('terminal.saveSettingsFailed'))
}
}
@@ -11,7 +11,7 @@
<el-input
ref="searchInputRef"
v-model="searchKeyword"
placeholder="查找内容..."
:placeholder="t('terminal.searchPlaceholder')"
size="small"
class="search_input"
clearable
@@ -28,7 +28,7 @@
<el-icon style="color: var(--el-color-warning)">
<CircleClose />
</el-icon>
<span class="status_text">未找到</span>
<span class="status_text">{{ t('common.notFound') }}</span>
</template>
</span>
</div>
@@ -38,8 +38,8 @@
<el-tooltip placement="bottom">
<template #content>
<div style="max-width: 300px;">
搜索范围:终端缓冲区内的内容<br>
超出缓冲区的内容无法搜索
{{ t('terminal.searchBufferScope') }}<br>
{{ t('terminal.searchBufferLimit') }}
</div>
</template>
<el-icon class="search_info_icon" :size="16">
@@ -48,7 +48,7 @@
</el-tooltip>
<div class="search_actions">
<el-tooltip content="向上查找 (Shift+Enter)" placement="top">
<el-tooltip :content="t('terminal.findPrevious')" placement="top">
<el-button
:icon="ArrowUp"
size="small"
@@ -57,7 +57,7 @@
/>
</el-tooltip>
<el-tooltip content="向下查找 (Enter)" placement="top">
<el-tooltip :content="t('terminal.findNext')" placement="top">
<el-button
:icon="ArrowDown"
size="small"
@@ -66,27 +66,27 @@
/>
</el-tooltip>
<el-tooltip :content="isCaseSensitive ? '区分大小写' : '不区分大小写'" placement="top">
<el-tooltip :content="isCaseSensitive ? t('common.caseSensitive') : t('common.caseInsensitive')" placement="top">
<el-button
size="small"
:type="isCaseSensitive ? 'primary' : ''"
@click="toggleCaseSensitive"
>
Aa <!-- true=蓝色 false=灰色 -->
Aa
</el-button>
</el-tooltip>
<el-tooltip :content="isUseRegex ? '正则表达式' : '普通文本'" placement="top">
<el-tooltip :content="isUseRegex ? t('common.regex') : t('common.plainText')" placement="top">
<el-button
size="small"
:type="isUseRegex ? 'primary' : ''"
@click="toggleRegex"
>
.* <!-- true=蓝色 false=灰色 -->
.*
</el-button>
</el-tooltip>
<el-tooltip content="关闭" placement="top">
<el-tooltip :content="t('terminal.closeSearch')" placement="top">
<el-button
:icon="Close"
size="small"
@@ -100,6 +100,7 @@
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowUp, ArrowDown, Close, InfoFilled, CircleClose } from '@element-plus/icons-vue'
const props = defineProps({
@@ -114,6 +115,7 @@ const props = defineProps({
})
const emit = defineEmits(['close',])
const { t } = useI18n()
const visible = ref(false)
const searchKeyword = ref('')
@@ -243,8 +245,7 @@ const performSearch = (isNext = true) => {
regex: isUseRegex.value,
wholeWord: false,
decorations: {
// matchBackground: '#FFFF00', // 所有匹配项
activeMatchBackground: '#FF8C00', // 当前匹配项
activeMatchBackground: '#FF8C00',
activeMatchBorder: '#FF0000'
}
})
@@ -253,17 +254,14 @@ const performSearch = (isNext = true) => {
regex: isUseRegex.value,
wholeWord: false,
decorations: {
// matchBackground: '#FFFF00', // 所有匹配项
activeMatchBackground: '#FF8C00', // 当前匹配项
activeMatchBackground: '#FF8C00',
activeMatchBorder: '#FF0000'
}
})
// 更新状态和索引值
searchStatus.value = result ? 'found' : 'not-found'
if (result && totalMatches.value > 0) {
// 根据方向更新索引值
if (isNext) {
currentMatchIndex.value = currentMatchIndex.value >= totalMatches.value ? 1 : currentMatchIndex.value + 1
} else {
@@ -280,13 +278,10 @@ const findPrevious = () => {
performSearch(false)
}
// 处理Enter和Shift+Enter
const handleEnterKey = (event) => {
if (event.shiftKey) {
// Shift+Enter是向上查找
findPrevious()
} else {
// 只有Enter是向下查找
findNext()
}
}
@@ -309,9 +304,7 @@ const resetSearch = () => {
searchStatus.value = ''
currentMatchIndex.value = 0
if (searchKeyword.value) {
// 重新计数
countMatches()
// 从第一个开始
currentMatchIndex.value = 1
performSearch(true)
}
@@ -2,75 +2,75 @@
<el-dialog
v-model="visible"
width="600px"
title="终端会话设置"
:title="t('terminal.terminalSessionSettings')"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="140px">
<el-form-item label="最大挂起时间">
<el-form-item :label="t('terminal.sessionMaxSuspendTime')">
<el-input-number
v-model="formData.maxSuspendTime"
:min="1"
:max="9999"
:step="1"
/>
<span style="margin-left: 10px">小时</span>
<div class="form_item_tip">(超过该时间后会话将自动销毁)</div>
<span style="margin-left: 10px">{{ t('terminal.hours') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionMaxSuspendTimeTip') }}</div>
</el-form-item>
<el-form-item label="最大挂起数">
<el-form-item :label="t('terminal.sessionMaxSuspendCount')">
<el-input-number
v-model="formData.maxSuspendedPerUser"
:min="1"
:max="20"
:step="1"
/>
<span style="margin-left: 10px">个</span>
<div class="form_item_tip">(可同时挂起的会话数量上限)</div>
<span style="margin-left: 10px">{{ t('terminal.countUnit') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionMaxSuspendCountTip') }}</div>
</el-form-item>
<el-form-item label="心跳检测间隔">
<el-form-item :label="t('terminal.sessionHeartbeatInterval')">
<el-input-number
v-model="formData.heartbeatInterval"
:min="10"
:max="300"
:step="10"
/>
<span style="margin-left: 10px">秒</span>
<div class="form_item_tip">(挂起时检测会话连接状态的间隔)</div>
<span style="margin-left: 10px">{{ t('terminal.seconds') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionHeartbeatIntervalTip') }}</div>
</el-form-item>
<el-form-item label="重连尝试次数">
<el-form-item :label="t('terminal.sessionReconnectAttempts')">
<el-input-number
v-model="formData.maxReconnectAttempts"
:min="0"
:max="10"
:step="1"
/>
<span style="margin-left: 10px">次</span>
<div class="form_item_tip">(连接断开后自动重连的最大尝试次数)</div>
<span style="margin-left: 10px">{{ t('terminal.times') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionReconnectAttemptsTip') }}</div>
</el-form-item>
<el-form-item label="重连间隔">
<el-form-item :label="t('terminal.sessionReconnectInterval')">
<el-input-number
v-model="formData.reconnectInterval"
:min="10"
:max="300"
:step="10"
/>
<span style="margin-left: 10px">秒</span>
<div class="form_item_tip">(两次重连尝试之间的间隔时间)</div>
<span style="margin-left: 10px">{{ t('terminal.seconds') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionReconnectIntervalTip') }}</div>
</el-form-item>
<el-form-item label="输出缓存大小">
<el-form-item :label="t('terminal.sessionBufferSize')">
<el-input-number
v-model="formData.maxBufferSize"
:min="10"
:max="500"
:step="10"
/>
<span style="margin-left: 10px">KB</span>
<div class="form_item_tip">(挂起期间缓存的终端输出最大大小)</div>
<span style="margin-left: 10px">{{ t('terminal.kilobytes') }}</span>
<div class="form_item_tip">{{ t('terminal.sessionBufferSizeTip') }}</div>
</el-form-item>
</el-form>
@@ -83,11 +83,11 @@
@click="handleRestoreDefaults"
style="margin-right: 20px;"
>
恢复默认
{{ t('common.restoreDefault') }}
</el-button>
<div class="btn_action">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
<el-button @click="handleClose">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</el-button>
</div>
</span>
</template>
@@ -96,8 +96,10 @@
<script setup>
import { ref, watch, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
const { proxy: { $message, $api } } = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -151,7 +153,7 @@ const handleSave = async () => {
saving.value = true
try {
await $api.updateTerminalSessionConfig({ config: formData.value })
$message.success('保存成功')
$message.success(t('terminal.saveSuccess'))
handleClose()
} finally {
saving.value = false
@@ -1,7 +1,7 @@
<template>
<el-drawer
v-model="visible"
title="基础设置"
:title="t('terminal.basicSettings')"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
@@ -15,10 +15,10 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="终端主题" prop="theme">
<el-form-item :label="t('terminal.terminalTheme')" prop="theme">
<el-select
v-model="theme"
placeholder="选择主题"
:placeholder="t('terminal.selectTheme')"
style="width: 100%;"
:teleported="false"
>
@@ -43,10 +43,10 @@
</el-option>
</el-select>
</el-form-item>
<el-form-item label="终端字体" prop="fontFamily">
<el-form-item :label="t('terminal.terminalFont')" prop="fontFamily">
<el-select
v-model="fontFamily"
placeholder="选择字体"
:placeholder="t('terminal.selectFont')"
style="width: 100%;"
:teleported="false"
>
@@ -58,13 +58,13 @@
/>
</el-select>
</el-form-item>
<el-form-item label="字体大小" prop="fontSize">
<el-form-item :label="t('terminal.fontSize')" prop="fontSize">
<el-input-number v-model="fontSize" :min="6" :max="30" />
</el-form-item>
<el-form-item label="字体颜色" prop="fontColor">
<el-form-item :label="t('terminal.fontColor')" prop="fontColor">
<div class="font_color_list">
<div :class="['color_item', 'theme_color', { 'active': !fontColor }]" @click="setThemeColor">
<span class="color_text">主题颜色</span>
<span class="color_text">{{ t('terminal.themeColor') }}</span>
</div>
<div :class="['color_item', 'color_picker_wrapper', { 'active': !!fontColor }]">
<el-color-picker
@@ -77,10 +77,10 @@
</div>
</div>
</el-form-item>
<el-form-item label="光标颜色" prop="cursorColor">
<el-form-item :label="t('terminal.cursorColor')" prop="cursorColor">
<div class="font_color_list">
<div :class="['color_item', 'theme_color', { 'active': !cursorColor }]" @click="setThemeCursorColor">
<span class="color_text">主题颜色</span>
<span class="color_text">{{ t('terminal.themeColor') }}</span>
</div>
<div :class="['color_item', 'color_picker_wrapper', { 'active': !!cursorColor }]">
<el-color-picker
@@ -93,7 +93,7 @@
</div>
</div>
</el-form-item>
<el-form-item label="选中颜色" prop="selectionColor">
<el-form-item :label="t('terminal.selectionColor')" prop="selectionColor">
<div class="font_color_list">
<div class="color_item color_picker_wrapper active">
<el-color-picker
@@ -105,13 +105,13 @@
</div>
</div>
</el-form-item>
<el-form-item label="终端背景" prop="backgroundImage">
<el-form-item :label="t('terminal.terminalBackground')" prop="backgroundImage">
<ul class="background_list">
<li :class="background ? '' : 'active'" @click="changeBackground('')">
<el-image class="image">
<template #error>
<div class="theme_background_text">
主题背景
{{ t('terminal.themeBackground') }}
</div>
</template>
</el-image>
@@ -128,7 +128,7 @@
<el-input
v-model="background"
clearable
placeholder="自定义背景图片url"
:placeholder="t('terminal.customBackgroundUrl')"
autocomplete="on"
/>
</div>
@@ -136,7 +136,7 @@
</el-form>
<template #footer>
<span class="dialog_footer">
<el-button @click="visible = false">关闭</el-button>
<el-button @click="visible = false">{{ t('common.close') }}</el-button>
</span>
</template>
</el-drawer>
@@ -144,11 +144,13 @@
<script setup>
import { computed, getCurrentInstance, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import themeList from 'xterm-theme'
import useMobileWidth from '@/composables/useMobileWidth'
const { proxy: { $store } } = getCurrentInstance()
const { isMobileScreen } = useMobileWidth()
const { t } = useI18n()
const props = defineProps({
show: {
@@ -221,7 +223,6 @@ const fontColor = computed({
const customFontColor = computed({
get: () => {
// 如果有自定义颜色,显示自定义颜色;否则显示上次选择的颜色
return fontColor.value || lastCustomColor.value
},
set: (newVal) => {
@@ -419,4 +420,4 @@ const changeBackground = (item) => {
margin-bottom: 0 !important;
}
}
</style>
</style>
@@ -69,7 +69,7 @@
</div>
<el-dialog
v-model="scriptInputStates[panel.terminal.key]"
title="脚本输入"
:title="t('terminal.scriptInput')"
top="20vh"
width="60%"
height="50vh"
@@ -101,6 +101,7 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { Close, FullScreen, Aim } from '@element-plus/icons-vue'
import Terminal from './terminal.vue'
import { terminalStatusList, terminalStatus } from '@/utils/enum'
@@ -140,6 +141,7 @@ const props = defineProps({
const containerRef = ref(null)
const { proxy: { $message } } = getCurrentInstance()
const { t } = useI18n()
const { showMenu } = useContextMenu()
const emit = defineEmits(['close-terminal', 'terminal-input', 'ping-data', 'reset-long-press', 'suspend-terminal'])
@@ -597,12 +599,12 @@ const handleCloseTerminal = (terminalKey) => {
const handleSuspendTerminalSingle = async (terminalItem, { silent = false } = {}) => {
const terminalRef = terminalRefs.value[terminalItem.key]
if (!terminalRef) {
if (!silent) $message.error('获取终端引用失败')
if (!silent) $message.error(t('terminal.terminalRefMissing'))
return false
}
const success = await terminalRef.suspendTerminal()
if (success) {
if (!silent) $message.success('终端已挂起')
if (!silent) $message.success(t('terminal.terminalSuspended'))
// 通知父组件关闭该终端
emit('suspend-terminal', terminalItem.key)
return true
@@ -614,7 +616,7 @@ const handleSuspendTerminalSingle = async (terminalItem, { silent = false } = {}
const handleSuspendAllSessionsSingle = async () => {
const connectedTabs = props.terminalTabs.filter(tab => tab.status === terminalStatus.CONNECT_SUCCESS)
if (connectedTabs.length === 0) {
$message.warning('暂无可挂起的已连接会话')
$message.warning(t('terminal.noSuspendableSessions'))
return
}
let successCount = 0
@@ -625,10 +627,10 @@ const handleSuspendAllSessionsSingle = async () => {
else failCount += 1
}
if (successCount > 0) {
$message.success(`已挂起 ${ successCount } 个会话`)
$message.success(t('terminal.suspendedSessionCount', { count: successCount }))
}
if (failCount > 0) {
$message.warning(`有 ${ failCount } 个会话挂起失败`)
$message.warning(t('terminal.suspendSessionFailCount', { count: failCount }))
}
}
@@ -9,7 +9,7 @@
class="dropdown_menu"
:teleported="isMobileScreen"
>
<span class="link_text">连接<el-icon class="link_icon"><arrow-down /></el-icon></span>
<span class="link_text">{{ t('common.connect') }}<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-cascader-panel
v-if="hostGroupCascader"
@@ -41,11 +41,11 @@
:teleported="isMobileScreen"
@visible-change="handleResumeSessionDropdownChange"
>
<span class="link_text">会话<el-icon class="link_icon"><arrow-down /></el-icon></span>
<span class="link_text">{{ t('terminal.session') }}<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="suspendedSessions.length === 0" disabled>
<span>暂无挂起的会话</span>
<span>{{ t('terminal.noSuspendedSessionsShort') }}</span>
</el-dropdown-item>
<el-dropdown-item
v-for="session in suspendedSessions"
@@ -60,14 +60,14 @@
size="small"
style="margin-left: 8px"
>
断开
{{ t('common.disconnected') }}
</el-tag>
</div>
</el-dropdown-item>
<el-dropdown-item divided @click="showSessionSetting = true">
<span style="display: flex; align-items: center; gap: 5px;">
<el-icon><Setting /></el-icon>
会话设置
{{ t('terminal.sessionSettings') }}
</span>
</el-dropdown-item>
</el-dropdown-menu>
@@ -81,7 +81,7 @@
class="dropdown_menu"
:teleported="isMobileScreen"
>
<span class="link_text">脚本库<el-icon class="link_icon"><arrow-down /></el-icon></span>
<span class="link_text">{{ t('menu.scripts') }}<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-cascader-panel
v-if="scriptLibraryCascader"
@@ -108,17 +108,17 @@
trigger="click"
:teleported="isMobileScreen"
>
<span class="link_text">终端设置<el-icon class="link_icon"><arrow-down /></el-icon></span>
<span class="link_text">{{ t('terminal.terminalSettingsMenu') }}<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showSetting = true">
<span>基础设置</span>
<span>{{ t('terminal.basicSettings') }}</span>
</el-dropdown-item>
<el-dropdown-item @click="showHighlightSettings = true">
<span>高亮设置</span>
<span>{{ t('terminal.highlightSettings') }}</span>
</el-dropdown-item>
<el-dropdown-item @click="showOtherSettings = true">
<span>其他设置</span>
<span>{{ t('terminal.otherSettings') }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -127,14 +127,14 @@
trigger="click"
:teleported="isMobileScreen"
>
<span class="link_text">功能项<el-icon class="link_icon"><arrow-down /></el-icon></span>
<span class="link_text">{{ t('terminal.features') }}<el-icon class="link_icon"><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleFullScreen">
<span>全屏</span>
<span>{{ t('common.fullscreen') }}</span>
</el-dropdown-item>
<el-dropdown-item @click="showMenuOptions = true">
<span>菜单选项</span>
<span>{{ t('terminal.menuOptions') }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -144,7 +144,7 @@
<div class="switch_wrap">
<el-tooltip
effect="dark"
content="同步输入到所有终端"
:content="t('terminal.syncInputAll')"
placement="bottom"
>
<el-switch
@@ -155,15 +155,15 @@
--el-switch-on-color: #13ce66;
--el-switch-off-color: #ff4949;
"
active-text="同步"
inactive-text="同步"
:active-text="t('terminal.sync')"
:inactive-text="t('terminal.sync')"
/>
</el-tooltip>
</div>
<div v-if="!isSingleWindowMode" class="switch_wrap">
<el-tooltip
effect="dark"
content="包含脚本库与长指令输入"
:content="t('terminal.includesScriptLibrary')"
placement="bottom"
>
<el-switch
@@ -174,8 +174,8 @@
--el-switch-on-color: #13ce66;
--el-switch-off-color: #ff4949;
"
active-text="长指令"
inactive-text="长指令"
:active-text="t('terminal.longCommand')"
:inactive-text="t('terminal.longCommand')"
@change="changeShowFooterBar"
/>
</el-tooltip>
@@ -184,7 +184,7 @@
<el-tooltip
effect="dark"
placement="bottom"
:content="isSingleWindowMode ? '多窗口展示多个服务器终端' : '单窗口一屏展示不同的服务器终端'"
:content="isSingleWindowMode ? t('terminal.multiWindowModeTip') : t('terminal.singleWindowModeTip')"
>
<el-switch
v-model="isSingleWindowMode"
@@ -194,21 +194,20 @@
--el-switch-on-color: #409eff;
--el-switch-off-color: #13ce66;
"
active-text="单窗口"
inactive-text="多窗口"
:active-text="t('terminal.singleWindowMode')"
:inactive-text="t('terminal.multiWindowMode')"
@change="changeIsSingleWindowMode"
/>
</el-tooltip>
</div>
<div v-if="!isMobileScreen && isSingleWindowMode && terminalTabs.length > 4" class="layout_mode_wrap">
<el-radio-group v-model="layoutMode" size="small">
<el-radio-button value="grid">一屏展示</el-radio-button>
<el-radio-button value="scroll">横向排列</el-radio-button>
<el-radio-button value="grid">{{ t('terminal.gridLayout') }}</el-radio-button>
<el-radio-button value="scroll">{{ t('terminal.scrollLayout') }}</el-radio-button>
</el-radio-group>
</div>
</div>
</div>
<!-- 单窗口模式 -->
<div v-if="isSingleWindowMode" class="single_window_wrapper">
<TerminalSingleWindow
ref="singleWindowRef"
@@ -225,7 +224,6 @@
/>
</div>
<!-- 多窗口模式 -->
<el-tabs
v-else
v-model="activeTabIndex"
@@ -252,8 +250,7 @@
:style="{ background: getStatusColor(item.status) }"
/>
<span>{{ item.name }}</span>
<!-- 挂起状态图标 -->
<el-icon v-if="item.status === 'suspended'" class="suspended_icon" title="已挂起">
<el-icon v-if="item.status === 'suspended'" class="suspended_icon" :title="t('terminal.suspended')">
<VideoPause />
</el-icon>
</div>
@@ -261,38 +258,37 @@
<div class="tab_content_wrap">
<div class="tab_content_header">
<div :class="['tab_content_wrap_header_item', { 'active': showInfoSide }]">
<span title="状态" @click="changeInfoSide">
<span :title="t('terminal.statusPanel')" @click="changeInfoSide">
<svg-icon name="icon-zhuangtai" class="icon" />
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': showSftpSide }]">
<span title="SFTP文件管理" @click="changeSftp">
<span :title="t('terminal.sftpManager')" @click="changeSftp">
<svg-icon name="icon-sftp" class="icon" />
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': showDockerDialog }]">
<span title="docker容器管理" @click="() => (showDockerDialog = !showDockerDialog)">
<span :title="t('terminal.dockerManager')" @click="() => (showDockerDialog = !showDockerDialog)">
<svg-icon name="icon-docker" class="icon" />
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': getSyncCurTab(item.key) }]">
<span title="同步输入到分屏" @click="handleSyncCurTabInput">
<span :title="t('terminal.syncInputSplit')" @click="handleSyncCurTabInput">
<svg-icon name="icon-lianjie" class="icon" />
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': getSplitStatus(item.key).h }]">
<span title="左右分屏" @click="handleHorizontalScreen">
<span :title="t('terminal.splitHorizontal')" @click="handleHorizontalScreen">
<svg-icon name="icon-a-06gaodufenping" class="icon" />
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': getSplitStatus(item.key).v }]">
<span title="上下分屏" @click="handleVerticalScreen">
<span :title="t('terminal.splitVertical')" @click="handleVerticalScreen">
<svg-icon name="icon-a-05kuandufenping" class="icon" />
</span>
</div>
</div>
<div class="tab_content_main">
<!-- 移动端 -->
<el-drawer
v-if="isMobileScreen"
v-model="showInfoSide"
@@ -307,7 +303,6 @@
:ping-ms="pingData[item.host] || 0"
/>
</el-drawer>
<!-- PC端 -->
<div v-else :class="['tab_content_main_info_side', { 'show_info_side': showInfoSide }]">
<ServerStatus
ref="infoSideRef"
@@ -363,7 +358,6 @@
: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"
@@ -396,7 +390,7 @@
<el-dialog
v-model="showDockerDialog"
align-center
:title="`Docker容器管理-${ item.name }`"
:title="t('terminal.dockerManagerTitle', { name: item.name })"
:width="isMobileScreen ? '100vw' : '80vw'"
:style="isMobileScreen ? 'max-width: 100vw;' : 'max-width: 1300px;'"
:close-on-click-modal="false"
@@ -432,6 +426,7 @@ import {
onMounted,
onUnmounted
} from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, VideoPause, Setting } from '@element-plus/icons-vue'
import useMobileWidth from '@/composables/useMobileWidth'
import { terminalStatusList, terminalStatus } from '@/utils/enum'
@@ -453,6 +448,7 @@ import TerminalSessionSetting from './terminal-session-setting.vue'
const {
proxy: { $nextTick, $store, $message }
} = getCurrentInstance()
const { t } = useI18n()
const props = defineProps({
terminalTabs: {
@@ -468,13 +464,11 @@ const { showMenu } = useContextMenu()
const infoSideRef = ref(null)
const pingData = ref({})
const terminalRefs = ref([])
// const sftpRefs = ref([])
const activeTabIndex = ref(0)
const isSyncAllSession = ref(false)
const isSingleWindowMode = ref(isMobileScreen.value ? false : localStorage.getItem('isSingleWindowMode') === 'true')
const layoutMode = ref(localStorage.getItem('terminalLayoutMode') || 'grid')
// 监听布局模式变化
watch(layoutMode, (newMode) => {
localStorage.setItem('terminalLayoutMode', newMode)
})
@@ -502,31 +496,23 @@ const sftpRefs = ref([])
const resumeSessionDropdownRef = ref(null)
const resumeSessionDropdownVisible = ref(false)
const suspendedSessions = computed(() => $store.suspendedSessions)
const hasSuspendedSessions = computed(() => suspendedSessions.value.length > 0)
// 当前聚焦终端 uid
const focusedUid = ref(null)
const handleTabFocus = (uid) => {
focusedUid.value = uid
}
// ======================= 同步当前tab分屏状态 =======================
const syncCurTabMap = reactive({})
const getSyncCurTab = (key) => syncCurTabMap[key] || false
const handleSyncCurTabInput = () => {
const key = getTabKeyByIndex(activeTabIndex.value)
if (!key) return
syncCurTabMap[key] = !getSyncCurTab(key)
}
// ======================= 分屏面板激活状态 =======================
const activeSplitMap = reactive({})
const getActiveSplit = (key) => activeSplitMap[key] || 1
const setActiveSplit = (key, idx) => {
activeSplitMap[key] = idx
}
@@ -534,23 +520,16 @@ const setActiveSplit = (key, idx) => {
const getSplitItemClass = (key, panelIndex) => {
const classes = []
const { h, v } = getSplitStatus(key)
if (h && v) { // four split
if (h && v) {
const activeIdx = getActiveSplit(key)
if (panelIndex === activeIdx) classes.push(`active_split_${ panelIndex }`)
}
// for two split no special class
return classes
}
// ======================= 分屏状态 =======================
// 每个 tab(key) 对应的分屏状态 { h: boolean, v: boolean }
const splitStatusMap = reactive({})
const getTabKeyByIndex = (idx) => terminalTabs.value[idx]?.key
const getSplitStatus = (key) => splitStatusMap[key] || { h: false, v: false }
// 计算某 tab 需要渲染的终端数量
const getTerminalCountByIndex = (idx) => {
const key = getTabKeyByIndex(idx)
if (!key) return 0
@@ -576,7 +555,6 @@ const changeIsSingleWindowMode = () => {
localStorage.setItem('isSingleWindowMode', isSingleWindowMode.value)
}
// 防抖函数
const debounce = (func, delay) => {
let timeoutId
return (...args) => {
@@ -585,28 +563,20 @@ const debounce = (func, delay) => {
}
}
// 防抖版本的终端尺寸重计算
const debouncedResizeTerminal = debounce(() => {
resizeTerminal()
}, 100)
// 防抖版本的localStorage保存
const debouncedSaveToStorage = debounce((height) => {
localStorage.setItem('footerBarHeight', height.toString())
}, 300)
const handleFooterBarHeightChange = (height) => {
// 立即更新响应式状态(保证UI实时更新)
footerBarHeight.value = height
// 防抖保存到localStorage
debouncedSaveToStorage(height)
// 防抖触发终端尺寸重计算
debouncedResizeTerminal()
}
// SFTP宽度调整相关
const SFTP_WIDTH_KEY = 'easynode_sftp_width'
const isResizingSftp = ref(false)
const startX = ref(0)
@@ -616,7 +586,6 @@ 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'
@@ -626,10 +595,8 @@ const startResizeSftp = (e) => {
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
const deltaX = startX.value - e.clientX
const newWidth = Math.max(200, Math.min(800, startWidth.value + deltaX))
sftpWidth.value = newWidth
}
@@ -639,7 +606,6 @@ const stopResizeSftp = () => {
document.removeEventListener('mouseup', stopResizeSftp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
// 保存宽度到 localStorage
localStorage.setItem(SFTP_WIDTH_KEY, sftpWidth.value.toString())
debouncedResizeTerminal()
}
@@ -659,8 +625,6 @@ const getTerminalRefsOfTab = (idx) => {
}
const getFirstTerminalRefOfTab = (idx) => getTerminalRefsOfTab(idx)[0]
// ======================= 提供给模板使用的辅助函数 =======================
const getTerminalCount = (tabKey) => {
const { h, v } = getSplitStatus(tabKey)
return (h ? 2 : 1) * (v ? 2 : 1)
@@ -682,18 +646,12 @@ const hostList = computed(() => {
if (!Array.isArray($store.hostList)) return []
return $store.hostList.filter(item => item.connectType !== 'rdp')
})
// const curHost = computed(() =>
// hostList.value.find(
// (item) => item.host === terminalTabs.value[activeTabIndex.value]?.host
// )
// )
const scriptGroupList = computed(() => $store.scriptGroupList)
const scriptList = computed(() => $store.scriptList)
const scriptLibrary = computed(() => $store.menuSetting.scriptLibrary)
const scriptLibraryCascader = computed(
() => $store.menuSetting.scriptLibraryCascader
)
const scriptLibraryCascader = computed(() => $store.menuSetting.scriptLibraryCascader)
const hostGroupCascader = computed(() => $store.menuSetting.hostGroupCascader)
const formatHostGroupList = computed(() => {
const groupList = hostList.value.reduce((acc, item) => {
const groupName = hostGroupList.value.find((group) => group.id === item.group)?.name
@@ -712,7 +670,7 @@ const formatHostGroupList = computed(() => {
if (hosts.length > 1) {
children.unshift({
value: `${ hostGroupAll }${ hosts[0].group }`,
label: '全部连接'
label: t('terminal.allConnect')
})
}
return {
@@ -723,6 +681,7 @@ const formatHostGroupList = computed(() => {
})
return result
})
const formatScriptList = computed(() => {
const scriptsByGroup = scriptList.value.reduce((acc, script) => {
const groupId = script.group || 'default'
@@ -732,7 +691,7 @@ const formatScriptList = computed(() => {
acc[groupId].push({
value: script.id,
label: script.name,
command: script.command // 保存command用于执行脚本
command: script.command
})
return acc
}, {})
@@ -744,9 +703,7 @@ const formatScriptList = computed(() => {
})
const getStatusColor = (status) => {
return (
terminalStatusList.find((item) => item.value === status)?.color || 'gray'
)
return terminalStatusList.find((item) => item.value === status)?.color || 'gray'
}
const handleUpdateList = async ({ host }) => {
@@ -755,7 +712,7 @@ const handleUpdateList = async ({ host }) => {
let targetHost = hostList.value.find((item) => item.host === host)
if (targetHost) emit('add-host', targetHost)
} catch (err) {
$message.error('获取实例列表失败')
$message.error(t('common.fetchServerListFailed'))
console.error('获取实例列表失败: ', err)
}
}
@@ -770,7 +727,7 @@ const resetLongPress = () => {
}
const handleLinkHost = (hostDescObj) => {
if (!hostDescObj) return // clearCheckedNodes二次触发change事件
if (!hostDescObj) return
const id = Array.isArray(hostDescObj) ? hostDescObj.slice(-1)[0] : hostDescObj.id
if (id.startsWith(hostGroupAll)) {
const groupId = id.split(hostGroupAll)[1]
@@ -781,17 +738,16 @@ const handleLinkHost = (hostDescObj) => {
emit('add-host', host)
})
} else {
$message.warning('请先配置SSH连接信息')
$message.warning(t('terminal.reconnectRequired'))
}
} else {
const host = hostList.value.find((item) => item.id === id)
if (!host.isConfig) {
$message.warning('请先配置SSH连接信息')
$message.warning(t('terminal.reconnectRequired'))
hostFormVisible.value = true
updateHostData.value = { ...host }
return
}
// 正常新建连接,不恢复会话
emit('add-host', host)
}
setTimeout(() => {
@@ -800,57 +756,49 @@ const handleLinkHost = (hostDescObj) => {
}, 100)
}
// 处理恢复会话下拉菜单显示/隐藏
const handleResumeSessionDropdownChange = (visible) => {
if (visible) {
fetchSuspendedSessions()
}
}
// 获取挂起的会话列表
const fetchSuspendedSessions = () => {
$store.getSuspendedSessions()
}
// 从下拉菜单恢复会话
const handleResumeSessionFromDropdown = (session) => {
if (!session.connectionAlive) {
$message.warning('该会话的SSH连接已断开,无法恢复')
$message.warning(t('terminal.sessionSshDisconnected'))
return
}
const { hostId, host, sessionId } = session
// 创建一个特殊的tab,标记为恢复会话
const targetHost = hostList.value.find(item => item.id === hostId)
if (!targetHost) {
$message.error('未找到对应的主机配置')
$message.error(t('terminal.hostConfigNotFound'))
return
}
const { id, name, isConfig } = targetHost
terminalTabs.value.push({
key: sessionId, // 使用sessionId作为key
key: sessionId,
id,
name,
host,
status: terminalStatus.RESUMING,
isConfig,
resumeSessionId: sessionId // 标记为恢复会话
resumeSessionId: sessionId
})
// 关闭下拉菜单
resumeSessionDropdownVisible.value = false
resumeSessionDropdownRef.value?.handleClose()
}
// scriptDescObj: 脚本库对象、脚本命令或者包含command和useBase64的对象
const handleExecScript = async (scriptDescObj) => {
if (!scriptDescObj) return // clearCheckedNodes二次触发change事件
if (!scriptDescObj) return
let command = ''
let useBase64 = false
// 处理从footerbar传来的对象格式{command,useBase64}
if (scriptDescObj.command !== undefined) {
command = scriptDescObj.command
useBase64 = scriptDescObj.useBase64 || false
@@ -859,25 +807,21 @@ const handleExecScript = async (scriptDescObj) => {
if (id) {
const script = scriptList.value.find((item) => item.id === id)
command = script?.command
useBase64 = script?.useBase64 || false // 读取脚本的base64配置
useBase64 = script?.useBase64 || false
} else {
command = scriptDescObj
useBase64 = false // 外部传入的命令默认不用base64
useBase64 = false
}
}
if (!command) return $message.warning('未找到对应的脚本')
if (!command) return $message.warning(t('terminal.scriptNotFound'))
if (!isSyncAllSession.value) {
// 不同步时,使用 handleInputCommand(会处理分屏同步)
await handleInputCommand(command, 'script', useBase64)
} else {
// 同步输入到所有终端
if (isSingleWindowMode.value) {
// 单窗口模式:inputCommand 方法内部会处理同步逻辑
await $nextTick()
singleWindowRef.value?.inputCommandToTerminal(command, 'script', useBase64)
} else {
// 多窗口模式:遍历所有终端引用
terminalRefs.value.forEach((terminalRef) => {
terminalRef.inputCommand(command, 'script', useBase64)
})
@@ -914,7 +858,6 @@ const getPingData = ({ host, time }) => {
}
const handleSyncPathToSftp = (path) => {
// 获取当前标签页的SFTP组件引用
const sftpRef = sftpRefs.value[activeTabIndex.value]
if (sftpRef && sftpRef.switchToPath && showSftpSide.value) {
sftpRef.switchToPath(path, false)
@@ -930,7 +873,6 @@ watch(
terminalTabsLen,
() => {
let len = terminalTabsLen.value
// console.log('add tab:', len)
if (len > 0) {
activeTabIndex.value = len - 1
tabChange(activeTabIndex.value)
@@ -979,28 +921,24 @@ const handleFullScreen = () => {
}
const handleHorizontalScreen = () => {
// if (isMobileScreen.value) return $message.info('移动端暂不支持左右分屏')
const key = getTabKeyByIndex(activeTabIndex.value)
if (!key) return
const status = splitStatusMap[key] || { h: false, v: false }
splitStatusMap[key] = { ...status, h: !status.h }
nextTick(() => {
resizeTerminal()
// 重新聚焦原先终端
const ref = terminalRefs.value.find(r => r?.$?.uid === focusedUid.value)
ref?.focusTab ? ref.focusTab() : terminalRefs.value?.[0]?.focusTab()
})
}
const handleVerticalScreen = () => {
// if (isMobileScreen.value) return
const key = getTabKeyByIndex(activeTabIndex.value)
if (!key) return
const status = splitStatusMap[key] || { h: false, v: false }
splitStatusMap[key] = { ...status, v: !status.v }
nextTick(() => {
resizeTerminal()
// focusLastTerminalOfActive()
const ref = terminalRefs.value.find(r => r?.$?.uid === focusedUid.value)
ref?.focusTab ? ref.focusTab() : terminalRefs.value?.[0]?.focusTab()
})
@@ -1015,19 +953,13 @@ const resizeTerminal = () => {
const handleInputCommand = async (command, type = 'input', useBase64 = false) => {
if (isSingleWindowMode.value) {
// 单窗口模式下,使用 singleWindowRef 来执行命令
await $nextTick()
singleWindowRef.value?.inputCommandToTerminal(command, type, useBase64)
} else {
// 多窗口模式下,优先使用当前聚焦的终端
let targetTerminalRef = null
// 首先尝试找到当前聚焦的终端
if (focusedUid.value) {
targetTerminalRef = terminalRefs.value.find(ref => ref?.$?.uid === focusedUid.value)
}
// 如果没有找到聚焦的终端,则使用当前活跃标签页的第一个终端
if (!targetTerminalRef) {
targetTerminalRef = getFirstTerminalRefOfTab(activeTabIndex.value)
}
@@ -1036,12 +968,10 @@ const handleInputCommand = async (command, type = 'input', useBase64 = false) =>
targetTerminalRef?.focusTab()
targetTerminalRef?.inputCommand(command, type, useBase64)
// 处理分屏同步逻辑
const curTabKey = getTabKeyByIndex(activeTabIndex.value)
const isSyncCurTab = getSyncCurTab(curTabKey)
if (isSyncCurTab) {
// 同步到当前标签页的其他分屏终端
const tabTerminalRefs = getTerminalRefsOfTab(activeTabIndex.value)
const targetUid = targetTerminalRef?.$?.uid
@@ -1054,22 +984,18 @@ const handleInputCommand = async (command, type = 'input', useBase64 = false) =>
}
}
// 挂起终端
const handleSuspendTerminal = async (item, index, { silent = false } = {}) => {
const terminalRef = getFirstTerminalRefOfTab(index)
if (!terminalRef) {
if (!silent) $message.error('获取终端引用失败')
if (!silent) $message.error(t('terminal.terminalRefMissing'))
return false
}
const success = await terminalRef.suspendTerminal()
if (success) {
// 更新标签状态
item.status = terminalStatus.SUSPENDED
if (!silent) $message.success('终端已挂起')
// 立即关闭该tab
if (!silent) $message.success(t('terminal.terminalSuspended'))
removeTab(index)
// 刷新挂起会话列表
fetchSuspendedSessions()
return true
}
@@ -1077,7 +1003,6 @@ const handleSuspendTerminal = async (item, index, { silent = false } = {}) => {
return false
}
// 挂起所有会话(仅对已连接的终端生效)
const handleSuspendAllSessions = async () => {
const indicesToSuspend = terminalTabs.value
.map((tab, index) => ({ tab, index }))
@@ -1086,7 +1011,7 @@ const handleSuspendAllSessions = async () => {
.reverse()
if (indicesToSuspend.length === 0) {
$message.warning('暂无可挂起的已连接会话')
$message.warning(t('terminal.noSuspendableSessions'))
return
}
@@ -1102,14 +1027,13 @@ const handleSuspendAllSessions = async () => {
}
if (successCount > 0) {
$message.success(`已挂起 ${ successCount } 个会话`)
$message.success(t('terminal.suspendedSessionCount', { count: successCount }))
}
if (failCount > 0) {
$message.warning(`有 ${ failCount } 个会话挂起失败`)
$message.warning(t('terminal.suspendSessionFailCount', { count: failCount }))
}
}
// 关闭其他终端
const handleCloseOtherTabs = (keepIndex) => {
const toRemove = []
terminalTabs.value.forEach((_, index) => {
@@ -1117,14 +1041,11 @@ const handleCloseOtherTabs = (keepIndex) => {
toRemove.push(index)
}
})
// 从后往前删除,避免索引错乱
toRemove.reverse().forEach(index => {
removeTab(index)
})
}
// Tab 右键菜单处理(复用 useTerminalTabContextMenu composable)
// 在所有依赖函数定义后初始化 composable(避免 hoisting 问题)
const { handleTabContextMenu: _tabCtxMenu } = useTerminalTabContextMenu({
terminalTabs,
onSuspend: (item, index) => handleSuspendTerminal(item, index),
@@ -1139,7 +1060,6 @@ const handleTabContextMenu = (e, item, index) => {
_tabCtxMenu(e, item, index)
}
// 单窗口模式相关函数
const handleCloseTerminalSingle = (terminalKey) => {
const tabIndex = terminalTabs.value.findIndex(tab => tab.key === terminalKey)
if (tabIndex !== -1) {
@@ -1147,13 +1067,11 @@ const handleCloseTerminalSingle = (terminalKey) => {
}
}
// 单窗口模式下挂起完成后的处理(TerminalSingleWindow emit suspend-terminal 时触发)
const handleSuspendTerminalSingleDone = (terminalKey) => {
const tabIndex = terminalTabs.value.findIndex(tab => tab.key === terminalKey)
if (tabIndex !== -1) {
emit('removeTab', tabIndex)
}
// 刷新挂起会话列表
fetchSuspendedSessions()
}
@@ -1198,7 +1116,6 @@ watch(isSingleWindowMode, async() => {
onMounted(() => {
document.addEventListener('fullscreenchange', fullScreenCb)
// 初始化时检查是否有挂起的会话
fetchSuspendedSessions()
})
onUnmounted(() => {
@@ -1232,24 +1149,18 @@ onUnmounted(() => {
&.mobile {
overflow-x: scroll;
overflow-y: auto;
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 5px 0 15px;
// position: sticky;
// top: 0;
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
// z-index: 3;
user-select: none;
// :deep(.el-dropdown) {
// margin-top: -2px;
// }
.dropdown_menu {
:deep(.el-dropdown-menu) {
min-width: 120px;
@@ -1261,7 +1172,6 @@ onUnmounted(() => {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
white-space: nowrap;
// color: var(--el-color-primary);
cursor: pointer;
margin-right: 10px;
display: flex;
@@ -1328,7 +1238,6 @@ onUnmounted(() => {
border-radius: 50%;
margin-right: 5px;
transition: all 0.5s;
// background-color: var(--el-color-primary);
}
.suspended_icon {
@@ -1546,3 +1455,4 @@ onUnmounted(() => {
}
}
</style>
+64 -62
View File
@@ -22,6 +22,7 @@
<script setup>
import { ref, onMounted, computed, onBeforeUnmount, getCurrentInstance, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
// import CommandHistory from './command_history.vue'
// 自定义搜索组件,xterm-addon-search-bar已废弃
import TerminalSearch from './terminal-search.vue'
@@ -47,6 +48,7 @@ const instance = getCurrentInstance()
const { uid } = instance
const { proxy: { $api, $store, $notification, $router, $message, $messageBox } } = instance
const { t } = useI18n()
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
@@ -207,7 +209,7 @@ watch(keywordHighlight, (newVal) => {
watch(customHighlightRules, (newRules) => {
if (highlighter.value) {
if (highlightDebugMode.value) {
console.log('自定义高亮规则变化,更新高亮器:', newRules)
console.log('Custom highlight rules changed, updating highlighter:', newRules)
}
highlighter.value.updateCustomRules(newRules)
}
@@ -238,7 +240,7 @@ const connectIO = () => {
reconnectionAttempts: 0
})
socket.value.on('connect', () => {
console.log('/terminal socket已连接:', hostId.value)
console.log('/terminal socket connected:', hostId.value)
socketConnected.value = true
@@ -260,7 +262,7 @@ const connectIO = () => {
// 接收会话ID
socket.value.on('session_created', ({ sessionId: sid }) => {
sessionId.value = sid
console.log('会话已创建:', sid)
console.log('Session created:', sid)
})
// 处理恢复成功
@@ -278,7 +280,7 @@ const connectIO = () => {
}
}
term.value.write('\r\n\x1b[92m═══ 终端已从挂起状态恢复 ═══\x1b[0m\r\n')
term.value.write(`\r\n\x1b[92m${ t('terminal.sessionRestored') }\x1b[0m\r\n`)
handleResize()
// 自动发送回车以显示命令提示符
@@ -332,50 +334,50 @@ const connectIO = () => {
socket.value.on('terminal_connect_close', () => {
curStatus.value = CONNECT_FAIL
term.value.write('\r\n\x1b[91m终端主动断开连接, 回车重新发起连接\x1b[0m')
term.value.write(`\r\n\x1b[91m${ t('terminal.terminalClosed') }\x1b[0m`)
})
socket.value.on('terminal_connect_fail', (message) => {
curStatus.value = CONNECT_FAIL
term.value.write(`\r\n\x1b[91m连接终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
term.value.write(`\r\n\x1b[91m${ t('terminal.terminalConnectFailed', { message }) }\x1b[0m`)
})
socket.value.on('terminal_create_fail', (message) => {
curStatus.value = CONNECT_FAIL
term.value.write(`\r\n\x1b[91m创建终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
term.value.write(`\r\n\x1b[91m${ t('terminal.terminalCreateFailed', { message }) }\x1b[0m`)
})
})
socket.value.on('disconnect', (reason) => {
console.warn('terminal websocket 连接断开:', reason)
console.warn('Terminal WebSocket disconnected:', reason)
switch (reason) {
case 'io server disconnect':
reconnectTerminal(true, '服务端主动断开连接')
reconnectTerminal(true, t('terminal.serverDisconnected'))
break
case 'io client disconnect': // 客户端主动断开连接
break
case 'transport close':
reconnectTerminal(true, '本地网络连接异常')
reconnectTerminal(true, t('terminal.networkError'))
break
case 'transport error':
reconnectTerminal(true, '建立连接错误')
reconnectTerminal(true, t('terminal.connectError'))
break
case 'parse error':
reconnectTerminal(true, '数据解析错误')
reconnectTerminal(true, t('terminal.parseError'))
break
default:
reconnectTerminal(true, '连接意外断开')
reconnectTerminal(true, t('terminal.unexpectedDisconnect'))
}
})
socket.value.on('connect_error', (err) => {
console.error('EasyNode服务端连接错误:', err)
console.error('EasyNode server connection error:', err)
curStatus.value = CONNECT_FAIL
term.value.write('\r\n\x1b[91mError: 连接失败,请检查EasyNode服务端是否正常, 回车重新发起连接\x1b[0m \r\n')
term.value.write(`\r\n\x1b[91m${ t('terminal.checkServerStatusWithReconnect') }\x1b[0m \r\n`)
$notification({
title: '服务端连接失败',
message: '请检查EasyNode服务端是否正常',
title: t('terminal.serverConnectFailed'),
message: t('terminal.checkServerStatus'),
type: 'error'
})
})
@@ -388,10 +390,10 @@ const reconnectTerminal = (isCommonTips = false, tips) => {
socketConnected.value = false
if (isCommonTips) {
if (autoReconnect.value) {
term.value.write(`\r\n\x1b[91m${ tips },自动重连中...\x1b[0m \r\n`)
term.value.write(`\r\n\x1b[91m${ t('terminal.autoReconnecting', { reason: 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[91m${ t('terminal.manualReconnectHint', { reason: tips }) }\x1b[0m \r\n`)
}
} else {
term.value.write(`\r\n\x1b[91m${ tips }\x1b[0m \r\n`)
@@ -514,7 +516,7 @@ const enterTimer = ref(null)
const onData = () => {
term.value.onData((key) => {
if ('\r' === key && curStatus.value === CONNECT_FAIL) {
reconnectTerminal(false, '重新连接中...')
reconnectTerminal(false, t('terminal.reconnecting'))
return
}
if (!socket.value || !socketConnected.value) return
@@ -573,9 +575,9 @@ const handleMouseUp = async (e) => {
const plusTips = () => {
if (!isPlusActive.value) {
// $message.warning('Plus功能未激活')
$messageBox.confirm('Plus功能未激活', 'Warning', {
confirmButtonText: '前往设置',
cancelButtonText: '取消',
$messageBox.confirm(t('terminal.plusInactive'), 'Warning', {
confirmButtonText: t('terminal.goToSettings'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -588,7 +590,7 @@ const plusTips = () => {
const syncPathToSftp = () => {
if (curStatus.value !== CONNECT_SUCCESS) {
$message.warning('终端未连接')
$message.warning(t('terminal.terminalNotConnected'))
return
}
@@ -618,7 +620,7 @@ const syncPathToSftp = () => {
// 找到路径,通过emit发送给父组件
emit('sync-path-to-sftp', cleanPath)
tempPathSyncCallback.value = null
$message.success(`已同步路径: ${ cleanPath }`)
$message.success(t('terminal.pathSynced', { path: cleanPath }))
break
}
}
@@ -628,7 +630,7 @@ const syncPathToSftp = () => {
if (outputBuffer.length > 1000) {
tempPathSyncCallback.value = null
if (!pathFound) {
$message.error('未能获取路径')
$message.error(t('terminal.pathNotFound'))
}
}
}
@@ -638,7 +640,7 @@ const syncPathToSftp = () => {
if (tempPathSyncCallback.value) {
tempPathSyncCallback.value = null
if (!pathFound) {
$message.error('同步路径超时')
$message.error(t('terminal.syncPathTimeout'))
}
}
}, 3000)
@@ -652,56 +654,56 @@ const syncPathToSftp = () => {
const handleRightClick = async (e) => {
let str = term.value.getSelection().trim()
const sendToAI = str ? {
label: '发送到AI会话',
label: t('terminal.sendToAiSession'),
onClick: () => {
EventBus.$emit('sendToAIInput', `\`\`\`shell\n${ str }\n\`\`\`\n`)
term.value.clearSelection()
}
} : null
const search = {
label: '查找',
label: t('common.search'),
onClick: () => {
showSearchBar()
term.value.clearSelection()
}
}
const paste = {
label: '粘贴',
label: t('terminal.paste'),
onClick: () => {
handlePaste()
term.value.clearSelection()
}
}
const copySelection = str ? {
label: '复制',
label: t('terminal.copy'),
onClick: async () => {
await handleCopySelection()
focusTab()
}
} : null
const pasteSelection = str ? {
label: '粘贴选中内容',
label: t('terminal.pasteSelection'),
onClick: async () => {
await handleCopySelection()
handlePaste()
}
} : null
const clear = {
label: '清屏',
label: t('terminal.clearScreen'),
onClick: () => {
handleClear()
}
}
const reconnect = {
label: '重新连接',
label: t('terminal.reconnect'),
onClick: () => {
reconnectTerminal(false, '重新连接中...')
reconnectTerminal(false, t('terminal.reconnecting'))
term.value.clearSelection()
focusTab()
}
}
const syncToSftp = props.showSftpSide ? {
label: '同步目录到SFTP',
label: t('terminal.syncDirectoryToSftp'),
onClick: () => {
syncPathToSftp()
term.value.clearSelection()
@@ -709,14 +711,14 @@ const handleRightClick = async (e) => {
}
} : null
const dockerId = isDockerId(str) ? {
label: 'docker容器ID',
label: t('terminal.dockerContainerId'),
children: [
{
label: '[plus]检测选中内容可能为docker容器ID',
label: t('terminal.dockerIdDetected'),
disabled: true
},
{
label: `登录: docker exec -it ${ str } sh \n`,
label: `${ t('terminal.dockerLogin') }: docker exec -it ${ str } sh \n`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -724,7 +726,7 @@ const handleRightClick = async (e) => {
}
},
{
label: `停止: docker stop ${ str }`,
label: `${ t('terminal.dockerStop') }: docker stop ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -732,7 +734,7 @@ const handleRightClick = async (e) => {
}
},
{
label: `重启: docker restart ${ str }`,
label: `${ t('terminal.dockerRestart') }: docker restart ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -740,12 +742,12 @@ const handleRightClick = async (e) => {
}
},
{
label: `删除: docker rm -f ${ str }`,
label: `${ t('terminal.dockerRemove') }: docker rm -f ${ str }`,
onClick: () => {
if (!plusTips()) return
$messageBox.confirm(`确认删除该容器:${ str }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
$messageBox.confirm(t('terminal.confirmDeleteContainer', { id: str }), 'Warning', {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
@@ -755,7 +757,7 @@ const handleRightClick = async (e) => {
}
},
{
label: `日志: docker logs -f ${ str }`,
label: `${ t('terminal.dockerLogs') }: docker logs -f ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -766,14 +768,14 @@ const handleRightClick = async (e) => {
} : null
const dockerComposeYml = isDockerComposeYml(str) ? {
label: 'docker compose文件',
label: t('terminal.dockerComposeFile'),
children: [
{
label: '[plus]检测选中内容可能为docker compose文件',
label: t('terminal.dockerComposeDetected'),
disabled: true
},
{
label: '启动: docker compose up -d',
label: `${ t('terminal.dockerComposeUp') }: docker compose up -d`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -781,7 +783,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '停止并删除: docker compose down',
label: `${ t('terminal.dockerComposeDown') }: docker compose down`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -789,7 +791,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '重启: docker compose restart',
label: `${ t('terminal.dockerComposeRestart') }: docker compose restart`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -797,7 +799,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '查看日志: docker compose logs -f',
label: `${ t('terminal.dockerComposeLogs') }: docker compose logs -f`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -805,7 +807,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '拉取新镜像: docker compose pull',
label: `${ t('terminal.dockerComposePull') }: docker compose pull`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -813,7 +815,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '重建: docker compose up -d --force-recreate',
label: `${ t('terminal.dockerComposeRecreate') }: docker compose up -d --force-recreate`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -821,7 +823,7 @@ const handleRightClick = async (e) => {
}
},
{
label: '升级镜像: pull && down && up -d',
label: `${ t('terminal.dockerComposeUpgrade') }: pull && down && up -d`,
onClick: () => {
if (!plusTips()) return
focusTab()
@@ -833,7 +835,7 @@ const handleRightClick = async (e) => {
// 挂起选项(仅在已连接状态显示)
const suspend = curStatus.value === CONNECT_SUCCESS ? {
label: '挂起',
label: t('terminal.suspendedSessions'),
onClick: () => {
emit('request-suspend')
term.value.clearSelection()
@@ -878,7 +880,7 @@ const handlePaste = async () => {
term.value.clearSelection()
} catch (err) {
// HTTP环境下无法读取剪贴板,提示用户使用浏览器原生粘贴
console.warn('剪贴板读取失败,请使用 Ctrl+V 或右键粘贴:', err.message)
console.warn('Failed to read clipboard, please use Ctrl+V or right-click paste:', err.message)
// 不显示错误提示,因为 Ctrl+V 会触发浏览器原生粘贴事件
}
}
@@ -911,7 +913,7 @@ const inputCommand = (command, type = 'input', useBase64 = false) => {
const execExternalCommand = (command, useBase64 = false) => {
if (!socket.value || !socket.value.connected || curStatus.value !== CONNECT_SUCCESS) {
$message.error('终端连接已断开,无法执行指令')
$message.error(t('terminal.terminalDisconnectedCannotExecute'))
return
}
@@ -954,13 +956,13 @@ const suspendTerminal = () => {
if (!plusTips()) return
return new Promise((resolve) => {
if (!sessionId.value) {
$message.warning('会话未建立,无法挂起')
$message.warning(t('terminal.sessionNotEstablished'))
resolve(false)
return
}
if (curStatus.value !== CONNECT_SUCCESS) {
$message.warning('终端未连接,无法挂起')
$message.warning(t('terminal.terminalNotConnectedSuspend'))
resolve(false)
return
}
@@ -998,7 +1000,7 @@ const suspendTerminal = () => {
resolved = true
cleanup()
$message.error(`挂起失败: ${ msg }`)
$message.error(t('terminal.suspendFailed', { message: msg }))
resolve(false)
}
@@ -1012,7 +1014,7 @@ const suspendTerminal = () => {
resolved = true
cleanup()
$message.warning('挂起操作超时')
$message.warning(t('terminal.suspendTimeout'))
resolve(false)
}, 5000)
})
+30 -48
View File
@@ -3,10 +3,10 @@
<div v-if="showLinkTips" class="terminal_link_tips">
<div class="link_tables_wrapper">
<div class="table_section">
<h2 class="quick_link_text">最近连接</h2>
<h2 class="quick_link_text">{{ t('terminal.recentConnections') }}</h2>
<el-table :data="displayHostList" :show-header="false">
<template #empty>
<span class="link" @click="handleToServer">去连接</span>
<span class="link" @click="handleToServer">{{ t('terminal.goConnect') }}</span>
</template>
<el-table-column prop="name" label="name" />
<el-table-column width="220">
@@ -28,7 +28,7 @@
link
@click="linkTerminal(row)"
>
连接
{{ t('common.connect') }}
</el-button>
<el-button
v-else
@@ -36,7 +36,7 @@
link
@click="handleUpdateHost(row)"
>
配置ssh
{{ t('terminal.configureSsh') }}
</el-button>
</div>
</template>
@@ -46,10 +46,10 @@
<div class="table_section">
<h2 class="quick_link_text">
<span>挂起会话</span>
<span>{{ t('terminal.suspendedSessions') }}</span>
<el-icon
class="session_setting_icon"
title="会话设置"
:title="t('terminal.sessionSettings')"
@click="showSessionSetting = true"
>
<Setting />
@@ -61,21 +61,21 @@
type="primary"
@click="handleResumeAll"
>
一键恢复
{{ t('terminal.resumeAll') }}
</el-button>
</h2>
<el-table v-loading="loadingSessions" :data="suspendedSessions" :show-header="false">
<template #empty>
<span>无挂起终端会话</span>
<span>{{ t('terminal.noSuspendedSessions') }}</span>
</template>
<el-table-column prop="hostName" label="主机名" />
<el-table-column label="状态">
<el-table-column prop="hostName" :label="t('common.hostName')" />
<el-table-column :label="t('common.status')">
<template #default="{ row }">
<el-tag v-if="!row.connectionAlive" type="danger" size="small">断开</el-tag>
<el-tag v-else type="success" size="small">活跃</el-tag>
<el-tag v-if="!row.connectionAlive" type="danger" size="small">{{ t('common.disconnected') }}</el-tag>
<el-tag v-else type="success" size="small">{{ t('common.active') }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="suspendTime" label="挂起时间" />
<el-table-column prop="suspendTime" :label="t('terminal.suspendTime')" />
<el-table-column fixed="right" width="80px">
<template #default="{ row }">
<div class="actios_btns">
@@ -85,7 +85,7 @@
:disabled="!row.connectionAlive"
@click="handleResumeSession(row)"
>
恢复
{{ t('common.resume') }}
</el-button>
</div>
</template>
@@ -115,6 +115,7 @@
<script setup>
import { ref, computed, onActivated, getCurrentInstance, reactive, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import { Setting } from '@element-plus/icons-vue'
import TerminalWrapper from './components/terminal-wrapper.vue'
@@ -128,6 +129,7 @@ const { CONNECTING, RESUMING } = terminalStatus
const { proxy: { $store, $message, $api } } = getCurrentInstance()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
let terminalTabs = reactive([])
let hostFormVisible = ref(false)
@@ -139,26 +141,22 @@ let showSessionSetting = ref(false)
let showLinkTips = computed(() => !Boolean(terminalTabs.length))
let hostList = computed(() => $store.hostList)
// 显示最近连接的主机列表(从后端获取后在前端排序)
const displayHostList = computed(() => {
return hostList.value
.filter(item => item.lastConnectTime) // 只显示有连接记录的
.filter(item => item.lastConnectTime)
.map(item => ({
...item,
lastTime: item.lastConnectTime ? dayjs(item.lastConnectTime).format('YYYY-MM-DD HH:mm:ss') : ''
}))
.sort((a, b) => {
// 按最近连接时间降序排列
const aTime = a.lastConnectTime || 0
const bTime = b.lastConnectTime || 0
return bTime - aTime
})
})
// 防抖定时器
let refreshHostListTimer = null
// 防抖刷新hostList(避免批量连接时多次调用)
function debouncedRefreshHostList() {
if (refreshHostListTimer) {
clearTimeout(refreshHostListTimer)
@@ -167,20 +165,18 @@ function debouncedRefreshHostList() {
try {
await $store.getHostList()
} catch (error) {
console.error('刷新实例列表失败:', error)
console.error('Failed to refresh instance list:', error)
}
}, 500) // 500ms防抖
}, 500)
}
// 更新最近连接时间(调用后端API)
async function updateRecentHostList(targetHost) {
if (!targetHost) return
try {
await $api.updateLastConnectTime({ id: targetHost.id })
// 防抖刷新本地store中的hostList
debouncedRefreshHostList()
} catch (error) {
console.error('更新最近连接时间失败:', error)
console.error('Failed to update recent connection time:', error)
}
}
@@ -188,7 +184,6 @@ function linkTerminal(hostInfo) {
let targetHost = hostList.value.find(item => item.id === hostInfo.id)
const { id, host, name, isConfig } = targetHost
terminalTabs.push({ key: randomStr(16), id, name, host, status: CONNECTING, isConfig })
// 异步更新最近连接时间
updateRecentHostList(targetHost)
}
@@ -211,8 +206,8 @@ const handleUpdateList = async ({ host }) => {
let targetHost = hostList.value.find(item => item.host === host)
if (targetHost) linkTerminal(targetHost)
} catch (err) {
$message.error('获取实例列表失败')
console.error('获取实例列表失败: ', err)
$message.error(t('common.fetchServerListFailed'))
console.error('Failed to fetch instance list:', err)
}
}
@@ -230,7 +225,6 @@ onActivated(async () => {
})
if (!targetHosts || !targetHosts.length) return
terminalTabs.push(...targetHosts)
// 为每个主机更新最近连接时间
targetHosts.forEach(item => {
const targetHost = hostList.value.find(h => h.id === item.id)
if (targetHost) {
@@ -243,88 +237,76 @@ const handleCopy = (host) => {
clipboard.copy(host)
}
// 获取挂起的会话列表
const fetchSuspendedSessions = async () => {
loadingSessions.value = true
try {
await $store.getSuspendedSessions()
} catch (error) {
console.error('获取挂起会话列表失败:', error)
$message.error('获取挂起会话列表失败')
console.error('Failed to fetch suspended sessions:', error)
$message.error(t('terminal.noSuspendedSessions'))
} finally {
loadingSessions.value = false
}
}
// 恢复挂起的会话
const handleResumeSession = (session) => {
if (!session.connectionAlive) {
$message.warning('该会话的SSH连接已断开,无法恢复')
$message.warning(t('terminal.sessionSshDisconnected'))
return
}
const { hostId, sessionId } = session
// 查找对应的主机配置
const targetHost = hostList.value.find(item => item.id === hostId)
if (!targetHost) {
$message.error('未找到对应的主机配置')
$message.error(t('terminal.hostConfigNotFound'))
return
}
const { id, name, host, isConfig } = targetHost
// 创建恢复会话的tab
terminalTabs.push({
key: sessionId, // 使用sessionId作为key
key: sessionId,
id,
name,
host,
status: RESUMING,
isConfig,
resumeSessionId: sessionId // 标记为恢复会话
resumeSessionId: sessionId
})
}
// 一键恢复所有挂起会话
const handleResumeAll = () => {
// 过滤出可恢复的活跃会话
const sessionsToRestore = [...suspendedSessions.value].filter(s => s.connectionAlive)
if (sessionsToRestore.length === 0) {
$message.warning('没有可恢复的活跃会话')
$message.warning(t('terminal.noRestorableSessions'))
return
}
// 为每个会话调用恢复逻辑(直接复用handleResumeSession)
sessionsToRestore.forEach(session => {
handleResumeSession(session)
})
$message.success(`正在恢复 ${sessionsToRestore.length} 个挂起的会话`)
$message.success(t('terminal.restoringSessions', { count: sessionsToRestore.length }))
}
// 监听showLinkTips变化,当返回到初始界面时重新获取挂起会话列表
watch(showLinkTips, (newValue) => {
if (newValue) {
// 当showLinkTips变为true时(即关闭所有终端返回到初始界面时),重新获取挂起会话列表
fetchSuspendedSessions()
}
})
onMounted(() => {
// 组件挂载时获取挂起的会话列表
fetchSuspendedSessions()
})
onUnmounted(() => {
// 清理防抖定时器
if (refreshHostListTimer) {
clearTimeout(refreshHostListTimer)
refreshHostListTimer = null
}
})
</script>
<style lang="scss" scoped>
+42 -2
View File
@@ -830,6 +830,36 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@intlify/core-base@11.3.0":
version "11.3.0"
resolved "https://registry.npmmirror.com/@intlify/core-base/-/core-base-11.3.0.tgz#55e612501f08b564011cb16d9f4a08e9a05df3cb"
integrity sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==
dependencies:
"@intlify/devtools-types" "11.3.0"
"@intlify/message-compiler" "11.3.0"
"@intlify/shared" "11.3.0"
"@intlify/devtools-types@11.3.0":
version "11.3.0"
resolved "https://registry.npmmirror.com/@intlify/devtools-types/-/devtools-types-11.3.0.tgz#2670ac6a786ff56ac7b84f9af472771c7bc954fa"
integrity sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==
dependencies:
"@intlify/core-base" "11.3.0"
"@intlify/shared" "11.3.0"
"@intlify/message-compiler@11.3.0":
version "11.3.0"
resolved "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-11.3.0.tgz#84c5d2af4a2119f836c5f6835b4b88e5d4076d4b"
integrity sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==
dependencies:
"@intlify/shared" "11.3.0"
source-map-js "^1.0.2"
"@intlify/shared@11.3.0":
version "11.3.0"
resolved "https://registry.npmmirror.com/@intlify/shared/-/shared-11.3.0.tgz#7abb22f05e502f3c6bf64dc771c448cdaa81d864"
integrity sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@@ -1536,7 +1566,7 @@
"@vue/compiler-dom" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/devtools-api@^6.6.4":
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
version "6.6.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
@@ -5480,7 +5510,7 @@ sorted-array-functions@^1.3.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-js@^1.2.1:
source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -6143,6 +6173,16 @@ vue-eslint-parser@^9.4.3:
lodash "^4.17.21"
semver "^7.3.6"
vue-i18n@11:
version "11.3.0"
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.3.0.tgz#acb92847017f90b0fd96541cb3b08fdef50dc49c"
integrity sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==
dependencies:
"@intlify/core-base" "11.3.0"
"@intlify/devtools-types" "11.3.0"
"@intlify/shared" "11.3.0"
"@vue/devtools-api" "^6.5.0"
vue-router@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.5.0.tgz#58fc5fe374e10b6018f910328f756c3dae081f14"