feat: 优化剪贴板读取

This commit is contained in:
chaos-zhu
2026-02-08 15:35:22 +08:00
parent d6fd7964b5
commit 8a1b678432
10 changed files with 147 additions and 48 deletions

View File

@@ -32,6 +32,7 @@ import { computed } from 'vue'
import { EventBus } from '@/utils' import { EventBus } from '@/utils'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import clipboard from '@/utils/clipboard'
const props = defineProps({ const props = defineProps({
node: { node: {
@@ -106,13 +107,7 @@ const highlightedCode = computed(() => {
// 处理复制 // 处理复制
const handleCopy = () => { const handleCopy = () => {
const code = props.node?.code || props.node?.value || '' const code = props.node?.code || props.node?.value || ''
navigator.clipboard.writeText(code) clipboard.copy(code)
.then(() => {
ElMessage.success('复制成功')
})
.catch(() => {
ElMessage.error('复制失败')
})
} }
// 处理执行 // 处理执行

View File

@@ -271,6 +271,7 @@ import { useAIChat } from '@/composables/useAIChat'
import AiApiConfig from './ai-api-config.vue' import AiApiConfig from './ai-api-config.vue'
import { EventBus } from '@/utils' import { EventBus } from '@/utils'
import CustomCodeBlock from './custom-code-block.vue' import CustomCodeBlock from './custom-code-block.vue'
import clipboard from '@/utils/clipboard'
const { proxy: { $message, $store } } = getCurrentInstance() const { proxy: { $message, $store } } = getCurrentInstance()
@@ -444,13 +445,8 @@ const submit = async function (questionStr) {
await sendMessage(questionStr, activeModel.value) await sendMessage(questionStr, activeModel.value)
} }
const copyContent = async (content) => { const copyContent = (content) => {
try { clipboard.copy(content)
await navigator.clipboard.writeText(content)
$message.success('复制成功')
} catch (err) {
$message.error('复制失败')
}
} }
const handleSetting = () => { const handleSetting = () => {

View File

@@ -5,7 +5,6 @@ import { isValidDate } from '@/utils'
const useStore = defineStore('global', { const useStore = defineStore('global', {
state: () => ({ state: () => ({
serviceURI: null,
hostList: [], hostList: [],
groupList: [], groupList: [],
sshList: [], sshList: [],

108
web/src/utils/clipboard.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* 通用剪贴板工具
* - HTTPS环境: 使用 navigator.clipboard API
* - HTTP环境: 降级到 document.execCommand (兼容内网部署)
*/
import { ElMessage, ElMessageBox } from 'element-plus'
/**
* 检测是否支持现代剪贴板API
*/
function isModernAPISupported() {
return !!(navigator.clipboard && window.isSecureContext)
}
/**
* 使用传统方法复制文本 (HTTP环境兼容)
*/
function copyWithLegacyAPI(text) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
try {
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
const successful = document.execCommand('copy')
return successful
} catch (err) {
console.error('复制失败:', err)
return false
} finally {
document.body.removeChild(textarea)
}
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @param {boolean} showMessage - 是否显示提示消息,默认true
* @returns {Promise<boolean>} 是否成功
*/
export async function copyText(text, showMessage = true) {
if (!text) {
if (showMessage) ElMessage.warning('复制内容为空')
return false
}
let success = false
// 优先尝试现代API (HTTPS环境)
if (isModernAPISupported()) {
try {
await navigator.clipboard.writeText(text)
success = true
} catch (err) {
console.warn('现代API复制失败,降级到传统方法:', err)
success = copyWithLegacyAPI(text)
}
} else {
// HTTP环境使用传统方法
success = copyWithLegacyAPI(text)
}
if (showMessage) {
success ? ElMessage.success('复制成功') : ElMessage.error('复制失败')
}
return success
}
/**
* 从剪贴板读取文本
* 注意: HTTP环境下无法读取剪贴板
* @param {boolean} showMessage - 是否显示提示消息,默认false
* @returns {Promise<string>} 剪贴板文本
*/
export async function pasteText(showMessage = false) {
if (!isModernAPISupported()) {
ElMessageBox.confirm('浏览器安全限制,不支持剪贴板读取需配置https', '提示', {
confirmButtonText: '好的',
showCancelButton: false,
type: 'warning',
dangerouslyUseHTMLString: true
}).catch(() => {})
const errorMsg = 'HTTP环境不支持读取剪贴板,请使用 Ctrl+V 或右键粘贴'
throw new Error(errorMsg)
}
try {
const text = await navigator.clipboard.readText()
return text
} catch (err) {
if (showMessage) ElMessage.error('读取剪贴板失败')
throw err
}
}
// 默认导出
export default {
copy: copyText,
paste: pasteText
}

View File

@@ -196,7 +196,8 @@ export const isValidDate = (dateString) => {
return !isNaN(date.getTime()) && date instanceof Date return !isNaN(date.getTime()) && date instanceof Date
} }
const serviceURI = import.meta.env.DEV ? process.env.serviceURI : location.origin const { protocol, hostname } = location
const serviceURI = import.meta.env.DEV ? `${ protocol }//${ hostname }:8082` : location.origin
export const generateSocketInstance = (path, config = { forceNew: false, reconnection: true, reconnectionAttempts: 3 }, query = {}) => { export const generateSocketInstance = (path, config = { forceNew: false, reconnection: true, reconnectionAttempts: 3 }, query = {}) => {
return socketIo(serviceURI, { return socketIo(serviceURI, {
withCredentials: true, withCredentials: true,

View File

@@ -137,6 +137,7 @@
import { ref, computed, getCurrentInstance, nextTick } from 'vue' import { ref, computed, getCurrentInstance, nextTick } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue' import { ArrowDown } from '@element-plus/icons-vue'
import useMobileWidth from '@/composables/useMobileWidth' import useMobileWidth from '@/composables/useMobileWidth'
import clipboard from '@/utils/clipboard'
const { proxy: { $message, $messageBox, $api, $router, $store } } = getCurrentInstance() const { proxy: { $message, $messageBox, $api, $router, $store } } = getCurrentInstance()
@@ -246,9 +247,8 @@ const handleRemoveHost = async ({ id }) => {
}) })
} }
const handleCopy = async (host) => { const handleCopy = (host) => {
await navigator.clipboard.writeText(host) clipboard.copy(host)
$message.success({ message: '复制成功', center: true })
} }
const formatProxyType = ({ proxyType, jumpHosts, proxyServer }) => { const formatProxyType = ({ proxyType, jumpHosts, proxyServer }) => {

View File

@@ -217,6 +217,7 @@
import { ref, computed, getCurrentInstance, watch, onBeforeUnmount, nextTick, onMounted, toRaw, shallowRef } from 'vue' import { ref, computed, getCurrentInstance, watch, onBeforeUnmount, nextTick, onMounted, toRaw, shallowRef } from 'vue'
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js'
import { generateSocketInstance } from '@/utils' import { generateSocketInstance } from '@/utils'
import clipboard from '@/utils/clipboard'
// 注册Chart.js所有组件 // 注册Chart.js所有组件
Chart.register(...registerables) Chart.register(...registerables)
@@ -604,9 +605,8 @@ watch(
) )
// 工具函数 // 工具函数
const handleCopy = async () => { const handleCopy = () => {
await navigator.clipboard.writeText(host.value) clipboard.copy(host.value)
$message.success({ message: '复制成功', center: true })
} }
const handleUsedColor = (num) => { const handleUsedColor = (num) => {

View File

@@ -665,6 +665,7 @@ import unknowIcon from '@/assets/image/system/unknow.png'
import { useContextMenu } from '@/composables/useContextMenu' import { useContextMenu } from '@/composables/useContextMenu'
import TextEditor from '@/components/text-editor/index.vue' import TextEditor from '@/components/text-editor/index.vue'
import ImagePreview from '@/components/image-preview/index.vue' import ImagePreview from '@/components/image-preview/index.vue'
import clipboard from '@/utils/clipboard'
const emit = defineEmits(['exec-script', ]) const emit = defineEmits(['exec-script', ])
@@ -1592,11 +1593,7 @@ const cancelEditPath = () => {
} }
const copyCurrentPath = () => { const copyCurrentPath = () => {
navigator.clipboard.writeText(currentPath.value).then(() => { clipboard.copy(currentPath.value)
$message.success('路径已复制')
}).catch(() => {
$message.error('复制失败')
})
} }
const handleUpload = (type) => { const handleUpload = (type) => {
@@ -1962,8 +1959,7 @@ const onRowContextMenu = (row, _column, event) => {
fullPath = `${ currentPathValue }/${ row.name }` fullPath = `${ currentPathValue }/${ row.name }`
} }
fullPath = fullPath.replace(/\/+/g, '/') fullPath = fullPath.replace(/\/+/g, '/')
navigator.clipboard.writeText(fullPath) clipboard.copy(fullPath)
$message.success('已复制路径')
} }
}) })

View File

@@ -39,6 +39,7 @@ import { useContextMenu } from '@/composables/useContextMenu'
import { EventBus, isDockerId, isDockerComposeYml, generateSocketInstance } from '@/utils' import { EventBus, isDockerId, isDockerComposeYml, generateSocketInstance } from '@/utils'
import useMobileWidth from '@/composables/useMobileWidth' import useMobileWidth from '@/composables/useMobileWidth'
import { TerminalHighlighter } from '@/utils/highlighter' import { TerminalHighlighter } from '@/utils/highlighter'
import clipboard from '@/utils/clipboard'
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL, SUSPENDED, RESUMING } = terminalStatus const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL, SUSPENDED, RESUMING } = terminalStatus
@@ -552,11 +553,8 @@ const onData = () => {
const handleCopySelection = async () => { const handleCopySelection = async () => {
let str = term.value.getSelection().trim() let str = term.value.getSelection().trim()
if (!str) return if (!str) return
const text = new Blob([str,], { type: 'text/plain' })
const item = new ClipboardItem({ await clipboard.copy(str)
'text/plain': text
})
await navigator.clipboard.write([item,])
term.value.clearSelection() term.value.clearSelection()
} }
@@ -855,18 +853,24 @@ const handleClear = () => {
} }
const handlePaste = async () => { const handlePaste = async () => {
let key = await navigator.clipboard.readText() try {
// 规范换行符:无论来自 Windows (\r\n) 还是 Unix (\n),都统一替换成 \r let key = await clipboard.paste()
key = key.replace(/\r\n/g, '\r').replace(/\n/g, '\r') // 规范换行符:无论来自 Windows (\r\n) 还是 Unix (\n),都统一替换成 \r
// 如果粘贴的内容以换行符结尾则去掉换行符(防止自动执行) key = key.replace(/\r\n/g, '\r').replace(/\n/g, '\r')
while (key.endsWith('\n')) { // 如果粘贴的内容以换行符结尾则去掉换行符(防止自动执行)
key = key.slice(0, -1) while (key.endsWith('\n')) {
} key = key.slice(0, -1)
}
emit('inputCommand', key, uid) emit('inputCommand', key, uid)
socket.value.emit('input', key) socket.value.emit('input', key)
term.value.focus() term.value.focus()
term.value.clearSelection() term.value.clearSelection()
} catch (err) {
// HTTP环境下无法读取剪贴板,提示用户使用浏览器原生粘贴
console.warn('剪贴板读取失败,请使用 Ctrl+V 或右键粘贴:', err.message)
// 不显示错误提示,因为 Ctrl+V 会触发浏览器原生粘贴事件
}
} }
const focusTab = () => { const focusTab = () => {

View File

@@ -101,6 +101,7 @@ import TerminalWrapper from './components/terminal-wrapper.vue'
import HostForm from '../server/components/host-form.vue' import HostForm from '../server/components/host-form.vue'
import { randomStr } from '@utils/index.js' import { randomStr } from '@utils/index.js'
import { terminalStatus } from '@/utils/enum' import { terminalStatus } from '@/utils/enum'
import clipboard from '@/utils/clipboard'
const { CONNECTING, RESUMING } = terminalStatus const { CONNECTING, RESUMING } = terminalStatus
const { proxy: { $store, $message, $api } } = getCurrentInstance() const { proxy: { $store, $message, $api } } = getCurrentInstance()
@@ -216,9 +217,8 @@ onActivated(async () => {
}) })
}) })
const handleCopy = async (host) => { const handleCopy = (host) => {
await navigator.clipboard.writeText(host) clipboard.copy(host)
$message.success({ message: '复制成功', center: true })
} }
// 获取挂起的会话列表 // 获取挂起的会话列表