右键复制

This commit is contained in:
luojiaquan
2026-10-13 17:16:27 +08:00
parent d6a6ec4d53
commit 3598a0d669
5 changed files with 354 additions and 4 deletions
@@ -0,0 +1,206 @@
<template>
<Teleport to="body">
<ul
v-if="menu.visible"
ref="menuRef"
class="table-copy-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
@click.stop
@contextmenu.prevent
>
<li class="tcm-title">复制{{ menu.rows.length }}{{ entityLabel }}记录的列值</li>
<li
v-for="col in shownColumns"
:key="col.key"
class="tcm-item"
@click="handleCopy(col)"
>
{{ col.label }}
</li>
<li v-if="!shownColumns.length" class="tcm-empty">暂无可复制的列</li>
</ul>
</Teleport>
</template>
<script setup name="TableCopyMenu">
/**
* 表格右键复制列值菜单
*
* 用法:
* <el-table @row-contextmenu="(row, col, ev) => copyMenuRef.open(row, ev)">
* <table-copy-menu ref="copyMenuRef" :columns="copyColumns" :selected-rows="selectedRows" />
*
* columns: [{ key: '字段名', label: '列名', get?: (row) => string }]
* - get 缺省时取 row[key]
* visibleKeys: 传入时只显示其中的列(配合「列设置」当前可见列)
* selectedRows: 表格勾选行;非空时复制勾选行,否则只复制当前右键行
*/
const props = defineProps({
columns: {
type: Array,
default: () => []
},
selectedRows: {
type: Array,
default: () => []
},
visibleKeys: {
type: Array,
default: null
},
// 量词:客户/供应商用「条」,收款单用「笔」
entityLabel: {
type: String,
default: '条'
}
})
const { proxy } = getCurrentInstance()
const MENU_WIDTH = 190
const ITEM_HEIGHT = 34
const TITLE_HEIGHT = 46
const MENU_MAX_HEIGHT = 340
const menuRef = ref(null)
const menu = reactive({ visible: false, x: 0, y: 0, rows: [] })
const shownColumns = computed(() => {
if (!props.visibleKeys) return props.columns
return props.columns.filter(c => props.visibleKeys.includes(c.key))
})
/** 打开右键菜单:有勾选行则复制勾选行,否则只复制当前右键行 */
function open(row, event) {
// 阻止浏览器原生右键菜单
event.preventDefault()
menu.rows = props.selectedRows && props.selectedRows.length ? props.selectedRows : [row]
const height = Math.min(TITLE_HEIGHT + shownColumns.value.length * ITEM_HEIGHT, MENU_MAX_HEIGHT)
menu.x = Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)
menu.y = Math.min(event.clientY, window.innerHeight - height - 8)
menu.visible = true
addListeners()
}
function close() {
if (!menu.visible) return
menu.visible = false
removeListeners()
}
function onDocPointerDown(e) {
if (e.target.closest && e.target.closest('.table-copy-menu')) return
close()
}
function onDocKeydown(e) {
if (e.key === 'Escape') close()
}
function onWindowScroll() {
close()
}
function addListeners() {
document.addEventListener('mousedown', onDocPointerDown, true)
document.addEventListener('keydown', onDocKeydown)
window.addEventListener('scroll', onWindowScroll, true)
}
function removeListeners() {
document.removeEventListener('mousedown', onDocPointerDown, true)
document.removeEventListener('keydown', onDocKeydown)
window.removeEventListener('scroll', onWindowScroll, true)
}
/** 兼容非安全上下文(http)的剪贴板写入 */
async function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch (err) {
// 走降级方案
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch (err) {
ok = false
}
document.body.removeChild(textarea)
return ok
}
async function handleCopy(col) {
const getter = typeof col.get === 'function' ? col.get : row => (row[col.key] === null || row[col.key] === undefined ? '' : row[col.key])
const values = menu.rows
.map(getter)
.map(v => String(v == null ? '' : v).trim())
.filter(v => v !== '' && v !== '-')
close()
if (!values.length) {
proxy.$modal.msgWarning(`所选记录的「${col.label}」均为空,无可复制内容`)
return
}
const ok = await copyToClipboard(values.join('\n'))
if (ok) {
proxy.$modal.msgSuccess(`已复制 ${values.length} ${props.entityLabel}记录的「${col.label}」`)
} else {
proxy.$modal.msgError('复制失败,请检查浏览器剪贴板权限')
}
}
// keep-alive 页面切走时关闭菜单(菜单 teleport 在 body,否则会残留在页面上)
onDeactivated(close)
onBeforeUnmount(removeListeners)
defineExpose({ open, close })
</script>
<style scoped>
.table-copy-menu {
position: fixed;
z-index: 3000;
min-width: 170px;
max-height: 340px;
margin: 0;
padding: 4px 0;
overflow-y: auto;
list-style: none;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
box-shadow: var(--el-box-shadow-light);
user-select: none;
}
.table-copy-menu .tcm-title {
padding: 4px 14px 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
pointer-events: none;
border-bottom: 1px solid var(--el-border-color-lighter);
margin-bottom: 4px;
}
.table-copy-menu .tcm-item {
padding: 7px 16px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
}
.table-copy-menu .tcm-item:hover {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.table-copy-menu .tcm-empty {
padding: 8px 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>