✨ 全局支持表格单列文字拖选复制
This commit is contained in:
@@ -5,6 +5,11 @@
|
|||||||
@use './btn.scss';
|
@use './btn.scss';
|
||||||
@use './bms.scss';
|
@use './bms.scss';
|
||||||
|
|
||||||
|
::highlight(bms-table-text-copy-highlight) {
|
||||||
|
background-color: #3164db;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="basic-table">
|
<div class="basic-table" v-text-drag-copy="props.textDragCopy">
|
||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 -->
|
||||||
<basic-form
|
<basic-form
|
||||||
v-if="searchColumns.length"
|
v-if="searchColumns.length"
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
* :columns="columns"
|
* :columns="columns"
|
||||||
* :search-columns="searchColumns"
|
* :search-columns="searchColumns"
|
||||||
* :query="listPost"
|
* :query="listPost"
|
||||||
|
* :text-drag-copy="false" // 可关闭表格文字拖选复制
|
||||||
* @add="handleAdd"
|
* @add="handleAdd"
|
||||||
* >
|
* >
|
||||||
* <template #toolbar="{ selection, ids }">
|
* <template #toolbar="{ selection, ids }">
|
||||||
@@ -220,6 +221,11 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
// 是否支持在同一列中拖选文字并复制(特殊交互表格可关闭)
|
||||||
|
textDragCopy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
// 是否显示默认新增按钮(无 toolbar 插槽时生效)
|
// 是否显示默认新增按钮(无 toolbar 插槽时生效)
|
||||||
showAdd: {
|
showAdd: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
const HIGHLIGHT_NAME = 'bms-table-text-copy-highlight'
|
||||||
|
const EDITABLE_SELECTOR = [
|
||||||
|
'button',
|
||||||
|
'a',
|
||||||
|
'input',
|
||||||
|
'textarea',
|
||||||
|
'select',
|
||||||
|
'[contenteditable="true"]',
|
||||||
|
'[role="button"]',
|
||||||
|
'.el-button',
|
||||||
|
'.el-checkbox',
|
||||||
|
'.el-radio',
|
||||||
|
'.el-switch',
|
||||||
|
'.el-input',
|
||||||
|
'.el-select',
|
||||||
|
'.el-date-editor',
|
||||||
|
'.el-cascader',
|
||||||
|
'.el-autocomplete',
|
||||||
|
'.el-input-number'
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
const contexts = new Map()
|
||||||
|
let activeSelection = null
|
||||||
|
let dragging = false
|
||||||
|
let hasDragged = false
|
||||||
|
let suppressClickRoot = null
|
||||||
|
let listenersInstalled = false
|
||||||
|
|
||||||
|
function supportsHighlights() {
|
||||||
|
return Boolean(window.CSS?.highlights && typeof window.Highlight === 'function')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
window.CSS?.highlights?.delete(HIGHLIGHT_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
activeSelection = null
|
||||||
|
dragging = false
|
||||||
|
hasDragged = false
|
||||||
|
clearHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextForCell(cell) {
|
||||||
|
for (let element = cell; element; element = element.parentElement) {
|
||||||
|
const context = contexts.get(element)
|
||||||
|
if (context?.enabled) return context
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRows(bodyWrapper) {
|
||||||
|
return Array.from(bodyWrapper.querySelectorAll('tbody > tr.el-table__row'))
|
||||||
|
.filter(row => row.getClientRects().length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCell(target) {
|
||||||
|
const element = target instanceof Element ? target : target?.parentElement
|
||||||
|
const cell = element?.closest('td')
|
||||||
|
const bodyWrapper = cell?.closest('.el-table__body-wrapper')
|
||||||
|
const row = cell?.closest('tbody > tr.el-table__row')
|
||||||
|
if (!cell || !bodyWrapper || !row || cell.matches('.el-table-column--selection, .el-table__expand-column')) return null
|
||||||
|
if (element?.closest(EDITABLE_SELECTOR)) return null
|
||||||
|
|
||||||
|
const context = getContextForCell(cell)
|
||||||
|
if (!context || !context.el.contains(bodyWrapper)) return null
|
||||||
|
|
||||||
|
const rows = getRows(bodyWrapper)
|
||||||
|
const rowIndex = rows.indexOf(row)
|
||||||
|
const colIndex = Array.from(row.children).indexOf(cell)
|
||||||
|
if (rowIndex < 0 || colIndex < 0) return null
|
||||||
|
return { context, bodyWrapper, rowIndex, colIndex, cell }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellTextRoot(cell) {
|
||||||
|
return cell.querySelector('.cell') || cell
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellTextNodes(cell) {
|
||||||
|
const walker = document.createTreeWalker(cellTextRoot(cell), NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes = []
|
||||||
|
let node
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (node.nodeValue) nodes.push(node)
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(cell) {
|
||||||
|
return cellTextNodes(cell).map(node => node.nodeValue).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function caretOffsetInCell(cell, clientX, clientY) {
|
||||||
|
const root = cellTextRoot(cell)
|
||||||
|
let point = null
|
||||||
|
if (document.caretRangeFromPoint) {
|
||||||
|
const range = document.caretRangeFromPoint(clientX, clientY)
|
||||||
|
if (range) point = { node: range.startContainer, offset: range.startOffset }
|
||||||
|
} else if (document.caretPositionFromPoint) {
|
||||||
|
const position = document.caretPositionFromPoint(clientX, clientY)
|
||||||
|
if (position) point = { node: position.offsetNode, offset: position.offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point && (point.node === root || root.contains(point.node))) {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(root)
|
||||||
|
range.setEnd(point.node, point.offset)
|
||||||
|
return Math.min(range.toString().length, cellText(cell).length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = root.getBoundingClientRect()
|
||||||
|
return clientX <= rect.left + rect.width / 2 ? 0 : cellText(cell).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function textRangesInCell(cell, startOffset, endOffset) {
|
||||||
|
const ranges = []
|
||||||
|
let cursor = 0
|
||||||
|
cellTextNodes(cell).forEach(node => {
|
||||||
|
const length = node.nodeValue.length
|
||||||
|
const start = Math.max(startOffset, cursor)
|
||||||
|
const end = Math.min(endOffset, cursor + length)
|
||||||
|
if (end > start) {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, start - cursor)
|
||||||
|
range.setEnd(node, end - cursor)
|
||||||
|
ranges.push(range)
|
||||||
|
}
|
||||||
|
cursor += length
|
||||||
|
})
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTextSegments() {
|
||||||
|
if (!activeSelection) return []
|
||||||
|
const firstRow = Math.min(activeSelection.anchorRow, activeSelection.focusRow)
|
||||||
|
const lastRow = Math.max(activeSelection.anchorRow, activeSelection.focusRow)
|
||||||
|
const rows = getRows(activeSelection.bodyWrapper)
|
||||||
|
const segments = []
|
||||||
|
|
||||||
|
for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex += 1) {
|
||||||
|
const cell = rows[rowIndex]?.children[activeSelection.colIndex]
|
||||||
|
if (!cell) continue
|
||||||
|
|
||||||
|
const value = cellText(cell)
|
||||||
|
let start = 0
|
||||||
|
let end = value.length
|
||||||
|
if (activeSelection.anchorRow === activeSelection.focusRow) {
|
||||||
|
start = Math.min(activeSelection.anchorOffset, activeSelection.focusOffset)
|
||||||
|
end = Math.max(activeSelection.anchorOffset, activeSelection.focusOffset)
|
||||||
|
} else if (rowIndex === activeSelection.anchorRow) {
|
||||||
|
if (activeSelection.anchorRow < activeSelection.focusRow) start = activeSelection.anchorOffset
|
||||||
|
else end = activeSelection.anchorOffset
|
||||||
|
} else if (rowIndex === activeSelection.focusRow) {
|
||||||
|
if (activeSelection.focusRow > activeSelection.anchorRow) end = activeSelection.focusOffset
|
||||||
|
else start = activeSelection.focusOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
start = Math.max(0, Math.min(start, value.length))
|
||||||
|
end = Math.max(start, Math.min(end, value.length))
|
||||||
|
segments.push({ cell, start, end, text: value.slice(start, end) })
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHighlight() {
|
||||||
|
clearHighlight()
|
||||||
|
if (!activeSelection || !supportsHighlights()) return
|
||||||
|
const ranges = selectedTextSegments().flatMap(segment => textRangesInCell(segment.cell, segment.start, segment.end))
|
||||||
|
if (ranges.length) window.CSS.highlights.set(HIGHLIGHT_NAME, new window.Highlight(...ranges))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(event) {
|
||||||
|
suppressClickRoot = null
|
||||||
|
clearSelection()
|
||||||
|
if (event.button !== 0 || !supportsHighlights()) return
|
||||||
|
|
||||||
|
const cell = resolveCell(event.target)
|
||||||
|
if (!cell) return
|
||||||
|
|
||||||
|
const offset = caretOffsetInCell(cell.cell, event.clientX, event.clientY)
|
||||||
|
activeSelection = {
|
||||||
|
context: cell.context,
|
||||||
|
bodyWrapper: cell.bodyWrapper,
|
||||||
|
colIndex: cell.colIndex,
|
||||||
|
anchorRow: cell.rowIndex,
|
||||||
|
focusRow: cell.rowIndex,
|
||||||
|
anchorOffset: offset,
|
||||||
|
focusOffset: offset,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY
|
||||||
|
}
|
||||||
|
dragging = true
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event) {
|
||||||
|
if (!dragging || !activeSelection) return
|
||||||
|
|
||||||
|
if (Math.abs(event.clientX - activeSelection.startX) + Math.abs(event.clientY - activeSelection.startY) > 2) {
|
||||||
|
hasDragged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = resolveCell(document.elementFromPoint(event.clientX, event.clientY))
|
||||||
|
if (!cell || cell.context !== activeSelection.context || cell.bodyWrapper !== activeSelection.bodyWrapper || cell.colIndex !== activeSelection.colIndex) return
|
||||||
|
|
||||||
|
const offset = caretOffsetInCell(cell.cell, event.clientX, event.clientY)
|
||||||
|
if (activeSelection.focusRow === cell.rowIndex && activeSelection.focusOffset === offset) return
|
||||||
|
activeSelection.focusRow = cell.rowIndex
|
||||||
|
activeSelection.focusOffset = offset
|
||||||
|
renderHighlight()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!dragging) return
|
||||||
|
dragging = false
|
||||||
|
if (hasDragged && activeSelection) {
|
||||||
|
suppressClickRoot = activeSelection.context.el
|
||||||
|
requestAnimationFrame(() => { suppressClickRoot = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
const root = suppressClickRoot
|
||||||
|
if (!root || !root.contains(event.target)) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
suppressClickRoot = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy(event) {
|
||||||
|
if (!activeSelection || dragging || !event.clipboardData) return
|
||||||
|
const target = event.target instanceof Element ? event.target : event.target?.parentElement
|
||||||
|
if (target?.closest(EDITABLE_SELECTOR)) return
|
||||||
|
|
||||||
|
const text = selectedTextSegments().map(segment => segment.text).join('\n')
|
||||||
|
if (!text) return
|
||||||
|
event.clipboardData.setData('text/plain', text)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installListeners() {
|
||||||
|
if (listenersInstalled) return
|
||||||
|
document.addEventListener('mousedown', handleMouseDown, true)
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
document.addEventListener('click', handleClick, true)
|
||||||
|
document.addEventListener('copy', handleCopy, true)
|
||||||
|
listenersInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeListeners() {
|
||||||
|
if (!listenersInstalled) return
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.removeEventListener('click', handleClick, true)
|
||||||
|
document.removeEventListener('copy', handleCopy, true)
|
||||||
|
listenersInstalled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled(value) {
|
||||||
|
if (value && typeof value === 'object') return value.enabled !== false
|
||||||
|
return value !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mounted(el, binding) {
|
||||||
|
contexts.set(el, { el, enabled: isEnabled(binding.value) })
|
||||||
|
installListeners()
|
||||||
|
},
|
||||||
|
updated(el, binding) {
|
||||||
|
const context = contexts.get(el)
|
||||||
|
if (!context) return
|
||||||
|
context.enabled = isEnabled(binding.value)
|
||||||
|
if (!context.enabled || activeSelection?.context === context) clearSelection()
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
const context = contexts.get(el)
|
||||||
|
if (activeSelection?.context === context) clearSelection()
|
||||||
|
contexts.delete(el)
|
||||||
|
if (contexts.size === 0) removeListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import hasRole from './permission/hasRole'
|
import hasRole from './permission/hasRole'
|
||||||
import hasPermi from './permission/hasPermi'
|
import hasPermi from './permission/hasPermi'
|
||||||
import copyText from './common/copyText'
|
import copyText from './common/copyText'
|
||||||
|
import textDragCopy from './common/textDragCopy'
|
||||||
|
|
||||||
export default function directive(app){
|
export default function directive(app){
|
||||||
app.directive('hasRole', hasRole)
|
app.directive('hasRole', hasRole)
|
||||||
app.directive('hasPermi', hasPermi)
|
app.directive('hasPermi', hasPermi)
|
||||||
app.directive('copyText', copyText)
|
app.directive('copyText', copyText)
|
||||||
}
|
app.directive('textDragCopy', textDragCopy)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :query="listArBillLogs" row-key="logId" :selection="false">
|
<basic-table :columns="columns" :search-columns="searchColumns" :query="listArBillLogs" row-key="logId" :selection="false">
|
||||||
<template #col-result="{ row }"><el-tag :type="AR_LOG_RESULT_TYPES[row.result] || 'info'">{{ resultText(row.result) }}</el-tag></template>
|
<template #col-result="{ row }"><el-tag :type="AR_LOG_RESULT_TYPES[row.result] || 'info'">{{ resultText(row.result) }}</el-tag></template>
|
||||||
</basic-table>
|
</basic-table>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
arEnumLabel
|
arEnumLabel
|
||||||
} from '@/constants/ar/enums'
|
} from '@/constants/ar/enums'
|
||||||
|
|
||||||
const tableRef = ref()
|
|
||||||
// 日志筛选值与后端枚举保持一致,展示文案统一由枚举工具转换。
|
// 日志筛选值与后端枚举保持一致,展示文案统一由枚举工具转换。
|
||||||
const searchColumns = [
|
const searchColumns = [
|
||||||
{ label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' },
|
{ label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' },
|
||||||
@@ -26,13 +25,18 @@ const searchColumns = [
|
|||||||
{ label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' },
|
{ label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' },
|
||||||
{ label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' }
|
{ label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 状态变化列同时展示前后状态,便于追踪确认、作废和核销等生命周期操作。
|
// 状态变化列同时展示前后状态,便于追踪确认、作废和核销等生命周期操作。
|
||||||
const columns = [
|
const columns = [
|
||||||
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
|
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) },
|
||||||
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },
|
{ label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
|
||||||
{ label: '状态变化', prop: 'status', width: 160, formatter: row => `${statusText(row.fromStatus)} → ${statusText(row.toStatus)}` }, { label: '操作人', prop: 'operatorName', width: 110 },
|
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) },
|
||||||
|
{ label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },
|
||||||
|
{ label: '状态变化', prop: 'status', width: 160, formatter: row => `${statusText(row.fromStatus)} → ${statusText(row.toStatus)}` },
|
||||||
|
{ label: '操作人', prop: 'operatorName', width: 110 },
|
||||||
{ label: '来源', prop: 'operationSource', width: 100, formatter: row => sourceText(row.operationSource) },
|
{ label: '来源', prop: 'operationSource', width: 100, formatter: row => sourceText(row.operationSource) },
|
||||||
{ label: '结果', prop: 'result', width: 90, slot: 'result' }, { label: '时间', prop: 'createTime', width: 170, parseTime: true }
|
{ label: '结果', prop: 'result', width: 90, slot: 'result' },
|
||||||
|
{ label: '时间', prop: 'createTime', width: 170, parseTime: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
function objectTypeText(value) { return arEnumLabel(AR_LOG_OBJECT_TYPE_OPTIONS, value) }
|
function objectTypeText(value) { return arEnumLabel(AR_LOG_OBJECT_TYPE_OPTIONS, value) }
|
||||||
|
|||||||
Reference in New Issue
Block a user