diff --git a/web/src/components/ai-chat/custom-code-block.vue b/web/src/components/ai-chat/custom-code-block.vue index 33fb82a..7ec7fc3 100644 --- a/web/src/components/ai-chat/custom-code-block.vue +++ b/web/src/components/ai-chat/custom-code-block.vue @@ -32,6 +32,7 @@ import { computed } from 'vue' import { EventBus } from '@/utils' import { ElMessage } from 'element-plus' import hljs from 'highlight.js' +import clipboard from '@/utils/clipboard' const props = defineProps({ node: { @@ -106,13 +107,7 @@ const highlightedCode = computed(() => { // 处理复制 const handleCopy = () => { const code = props.node?.code || props.node?.value || '' - navigator.clipboard.writeText(code) - .then(() => { - ElMessage.success('复制成功') - }) - .catch(() => { - ElMessage.error('复制失败') - }) + clipboard.copy(code) } // 处理执行 diff --git a/web/src/components/ai-chat/index.vue b/web/src/components/ai-chat/index.vue index 1240347..c5ffd74 100644 --- a/web/src/components/ai-chat/index.vue +++ b/web/src/components/ai-chat/index.vue @@ -271,6 +271,7 @@ import { useAIChat } from '@/composables/useAIChat' import AiApiConfig from './ai-api-config.vue' import { EventBus } from '@/utils' import CustomCodeBlock from './custom-code-block.vue' +import clipboard from '@/utils/clipboard' const { proxy: { $message, $store } } = getCurrentInstance() @@ -444,13 +445,8 @@ const submit = async function (questionStr) { await sendMessage(questionStr, activeModel.value) } -const copyContent = async (content) => { - try { - await navigator.clipboard.writeText(content) - $message.success('复制成功') - } catch (err) { - $message.error('复制失败') - } +const copyContent = (content) => { + clipboard.copy(content) } const handleSetting = () => { diff --git a/web/src/store/index.js b/web/src/store/index.js index 1817a74..549db75 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -5,7 +5,6 @@ import { isValidDate } from '@/utils' const useStore = defineStore('global', { state: () => ({ - serviceURI: null, hostList: [], groupList: [], sshList: [], diff --git a/web/src/utils/clipboard.js b/web/src/utils/clipboard.js new file mode 100644 index 0000000..5901712 --- /dev/null +++ b/web/src/utils/clipboard.js @@ -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} 是否成功 + */ +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} 剪贴板文本 + */ +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 +} diff --git a/web/src/utils/index.js b/web/src/utils/index.js index 246a184..5977e8e 100644 --- a/web/src/utils/index.js +++ b/web/src/utils/index.js @@ -196,7 +196,8 @@ export const isValidDate = (dateString) => { 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 = {}) => { return socketIo(serviceURI, { withCredentials: true, diff --git a/web/src/views/server/components/host-table.vue b/web/src/views/server/components/host-table.vue index ebaa1b9..30cbe4a 100644 --- a/web/src/views/server/components/host-table.vue +++ b/web/src/views/server/components/host-table.vue @@ -137,6 +137,7 @@ import { ref, computed, getCurrentInstance, nextTick } from 'vue' 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() @@ -246,9 +247,8 @@ const handleRemoveHost = async ({ id }) => { }) } -const handleCopy = async (host) => { - await navigator.clipboard.writeText(host) - $message.success({ message: '复制成功', center: true }) +const handleCopy = (host) => { + clipboard.copy(host) } const formatProxyType = ({ proxyType, jumpHosts, proxyServer }) => { diff --git a/web/src/views/terminal/components/server-status.vue b/web/src/views/terminal/components/server-status.vue index dc7b94d..767ba4b 100644 --- a/web/src/views/terminal/components/server-status.vue +++ b/web/src/views/terminal/components/server-status.vue @@ -217,6 +217,7 @@ import { ref, computed, getCurrentInstance, watch, onBeforeUnmount, nextTick, onMounted, toRaw, shallowRef } from 'vue' import { Chart, registerables } from 'chart.js' import { generateSocketInstance } from '@/utils' +import clipboard from '@/utils/clipboard' // 注册Chart.js所有组件 Chart.register(...registerables) @@ -604,9 +605,8 @@ watch( ) // 工具函数 -const handleCopy = async () => { - await navigator.clipboard.writeText(host.value) - $message.success({ message: '复制成功', center: true }) +const handleCopy = () => { + clipboard.copy(host.value) } const handleUsedColor = (num) => { diff --git a/web/src/views/terminal/components/sftp-v2.vue b/web/src/views/terminal/components/sftp-v2.vue index 410dca5..53387f2 100644 --- a/web/src/views/terminal/components/sftp-v2.vue +++ b/web/src/views/terminal/components/sftp-v2.vue @@ -665,6 +665,7 @@ import unknowIcon from '@/assets/image/system/unknow.png' import { useContextMenu } from '@/composables/useContextMenu' import TextEditor from '@/components/text-editor/index.vue' import ImagePreview from '@/components/image-preview/index.vue' +import clipboard from '@/utils/clipboard' const emit = defineEmits(['exec-script', ]) @@ -1592,11 +1593,7 @@ const cancelEditPath = () => { } const copyCurrentPath = () => { - navigator.clipboard.writeText(currentPath.value).then(() => { - $message.success('路径已复制') - }).catch(() => { - $message.error('复制失败') - }) + clipboard.copy(currentPath.value) } const handleUpload = (type) => { @@ -1962,8 +1959,7 @@ const onRowContextMenu = (row, _column, event) => { fullPath = `${ currentPathValue }/${ row.name }` } fullPath = fullPath.replace(/\/+/g, '/') - navigator.clipboard.writeText(fullPath) - $message.success('已复制路径') + clipboard.copy(fullPath) } }) diff --git a/web/src/views/terminal/components/terminal.vue b/web/src/views/terminal/components/terminal.vue index 6a34779..ad598b3 100644 --- a/web/src/views/terminal/components/terminal.vue +++ b/web/src/views/terminal/components/terminal.vue @@ -39,6 +39,7 @@ import { useContextMenu } from '@/composables/useContextMenu' import { EventBus, isDockerId, isDockerComposeYml, generateSocketInstance } from '@/utils' import useMobileWidth from '@/composables/useMobileWidth' import { TerminalHighlighter } from '@/utils/highlighter' +import clipboard from '@/utils/clipboard' const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL, SUSPENDED, RESUMING } = terminalStatus @@ -552,11 +553,8 @@ const onData = () => { const handleCopySelection = async () => { let str = term.value.getSelection().trim() if (!str) return - const text = new Blob([str,], { type: 'text/plain' }) - const item = new ClipboardItem({ - 'text/plain': text - }) - await navigator.clipboard.write([item,]) + + await clipboard.copy(str) term.value.clearSelection() } @@ -855,18 +853,24 @@ const handleClear = () => { } const handlePaste = async () => { - let key = await navigator.clipboard.readText() - // 规范换行符:无论来自 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) - } + try { + let key = await clipboard.paste() + // 规范换行符:无论来自 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) + } - emit('inputCommand', key, uid) - socket.value.emit('input', key) - term.value.focus() - term.value.clearSelection() + emit('inputCommand', key, uid) + socket.value.emit('input', key) + term.value.focus() + term.value.clearSelection() + } catch (err) { + // HTTP环境下无法读取剪贴板,提示用户使用浏览器原生粘贴 + console.warn('剪贴板读取失败,请使用 Ctrl+V 或右键粘贴:', err.message) + // 不显示错误提示,因为 Ctrl+V 会触发浏览器原生粘贴事件 + } } const focusTab = () => { diff --git a/web/src/views/terminal/index.vue b/web/src/views/terminal/index.vue index 3dc2b1c..8466b5a 100644 --- a/web/src/views/terminal/index.vue +++ b/web/src/views/terminal/index.vue @@ -101,6 +101,7 @@ import TerminalWrapper from './components/terminal-wrapper.vue' import HostForm from '../server/components/host-form.vue' import { randomStr } from '@utils/index.js' import { terminalStatus } from '@/utils/enum' +import clipboard from '@/utils/clipboard' const { CONNECTING, RESUMING } = terminalStatus const { proxy: { $store, $message, $api } } = getCurrentInstance() @@ -216,9 +217,8 @@ onActivated(async () => { }) }) -const handleCopy = async (host) => { - await navigator.clipboard.writeText(host) - $message.success({ message: '复制成功', center: true }) +const handleCopy = (host) => { + clipboard.copy(host) } // 获取挂起的会话列表