右键复制

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>
+3
View File
@@ -61,6 +61,8 @@ import ChannelSelect from '@/components/ChannelSelect'
import MultiValueInput from '@/components/MultiValueInput'
// 表格列设置组件(自定义列显隐)
import ColumnSettings from '@/components/ColumnSettings'
// 表格右键复制列值菜单
import TableCopyMenu from '@/components/TableCopyMenu'
const app = createApp(App)
@@ -93,6 +95,7 @@ app.component('ProductSelect', ProductSelect)
app.component('ChannelSelect', ChannelSelect)
app.component('MultiValueInput', MultiValueInput)
app.component('ColumnSettings', ColumnSettings)
app.component('TableCopyMenu', TableCopyMenu)
app.use(router)
app.use(store)
+41 -1
View File
@@ -49,9 +49,12 @@
已选 <b>{{ selectedReceipts.length }}</b> 笔收款,
收款总额(本位币):<b class="money-in">CNY {{ selectedCnyTotal }}</b>
<template v-if="selectedCurrencyText">,原币合计:{{ selectedCurrencyText }}</template>
<span class="copy-tip">(勾选后在表格内<strong>右键</strong>可复制所选列的值)</span>
</div>
<el-table :data="receiptList" border v-loading="receiptLoading" @selection-change="onReceiptSelectionChange">
<el-table :data="receiptList" border v-loading="receiptLoading"
@selection-change="onReceiptSelectionChange"
@row-contextmenu="onReceiptRowContextmenu">
<el-table-column type="selection" width="42" align="center" />
<el-table-column label="收款单号" align="center" prop="receiptNo" width="180" show-overflow-tooltip />
<el-table-column label="客户" align="center" min-width="180" show-overflow-tooltip>
@@ -657,6 +660,14 @@
<el-button @click="cancelWoDialog.visible = false">取 消</el-button>
</template>
</el-dialog>
<!-- ====================== 右键复制列菜单 ====================== -->
<table-copy-menu
ref="copyMenuRef"
:columns="copyColumns"
:selected-rows="selectedReceipts"
entity-label="笔"
/>
</div>
</template>
@@ -785,6 +796,29 @@ function onReceiptSelectionChange(rows) {
selectedReceipts.value = rows || []
}
/* ================= 右键复制列值(复用全局 TableCopyMenu) ================= */
// 可复制的列:label 为菜单显示名,get(row) 取该行列值
const copyColumns = [
{ key: 'receiptNo', label: '收款单号', get: r => r.receiptNo || '' },
{ key: 'customerName', label: '客户名称', get: r => r.customerName || '' },
{ key: 'customerCode', label: '客户编码', get: r => r.customerCode || '' },
{ key: 'receivedAt', label: '收款时间', get: r => (r.receivedAt ? formatDateTime(r.receivedAt) : '') },
{ key: 'amount', label: '原币金额', get: r => `${r.currency || ''} ${formatMoney(r.amount)}`.trim() },
{ key: 'baseAmount', label: '本位币金额(CNY)', get: r => formatMoney(r.baseAmount) },
{ key: 'payMethod', label: '收款方式', get: r => formatOption(payMethodOptions, r.payMethod) },
{ key: 'bankTransactionNo', label: '流水号', get: r => r.bankTransactionNo || '' }
]
const copyMenuRef = ref(null)
/** 表格行右键:有勾选则复制勾选行,否则只复制当前右键行(逻辑在 TableCopyMenu 内) */
function onReceiptRowContextmenu(row, column, event) {
copyMenuRef.value && copyMenuRef.value.open(row, event)
}
function closeContextMenu() {
copyMenuRef.value && copyMenuRef.value.close()
}
/** 选中收款的本位币总额(CNY) */
const selectedCnyTotal = computed(() => {
const sum = selectedReceipts.value.reduce((s, r) => s + (Number(r.baseAmount) || 0), 0)
@@ -801,6 +835,7 @@ const selectedCurrencyText = computed(() => {
})
function getReceiptList() {
closeContextMenu()
receiptLoading.value = true
receiptQuery.pageNum = receiptQuery.pageNum || 1
const params = proxy.addDateRange({ ...receiptQuery }, receiptDateRange.value)
@@ -1368,4 +1403,9 @@ getReceiptList()
font-weight: 600;
margin: 12px 0 8px;
}
.copy-tip {
color: var(--el-text-color-secondary);
font-weight: normal;
font-size: 12px;
}
</style>
@@ -129,7 +129,9 @@
<column-settings :columns="columnDefs" :visible-map="colVisible" @toggle="toggleColumn" @reset="resetColumns" />
</el-row>
<el-table v-loading="loading" border :data="customerList" @selection-change="handleSelectionChange">
<el-table v-loading="loading" border :data="customerList"
@selection-change="handleSelectionChange"
@row-contextmenu="onTableRowContextmenu">
<el-table-column type="selection" width="55" align="center" fixed="left" />
<!-- <el-table-column label="客户ID" align="center" prop="customerId" />-->
<el-table-column v-if="isColVisible('customerCode')" label="客户编码" width="100" align="center" prop="customerCode" />
@@ -285,13 +287,22 @@
:customer-name="logCustomer.customerName"
:customer-code="logCustomer.customerCode"
/>
<!-- 右键复制列值 -->
<table-copy-menu
ref="copyMenuRef"
:columns="copyColumns"
:selected-rows="selectedRows"
:visible-keys="visibleCopyKeys"
/>
</div>
</template>
<script setup name="Customer">
<script setup name="Customerinfo">
import { listCustomer, delCustomer } from "@/api/customersuppliermanagement/customerinfo/customer.js"
import { listPriceSetOption, batchBindCustomerPriceSet } from "@/api/customersuppliermanagement/customerinfo/customerPriceSet.js"
import { useProducts, parseProductCodes } from '@/components/ProductSelect/useProducts'
import { useUsers } from '@/components/UserSelect/useUsers'
import CustomerLogDialog from './components/CustomerLogDialog.vue'
import { useColumnSettings } from '@/utils/columnSettings'
@@ -339,12 +350,48 @@ function productNames(productCodes) {
}
ensureProducts()
/** 员工ID -> 姓名(与表格 UserName 展示同源) */
const { ensureUsers, getUserName } = useUsers()
ensureUsers()
/** 格式化余额(数据源 customer_account 余额表) */
function formatBalance(val) {
const num = Number(val)
return isNaN(num) ? '0.00' : num.toFixed(2)
}
/* ================= 右键复制列值 ================= */
const copyMenuRef = ref(null)
/** 右键可复制列(取值口径与表格展示一致) */
const copyColumns = computed(() => [
{ key: 'customerCode', label: '客户编码', get: r => r.customerCode || '' },
{ key: 'customerName', label: '客户名称', get: r => r.customerName || '' },
{ key: 'customerCodeDw', label: '对外代码', get: r => r.customerCodeDw || '' },
{ key: 'creditCode', label: '统一社会信用代码', get: r => r.creditCode || '' },
{ key: 'customerType', label: '业务类型', get: r => proxy.selectDictLabel(customer_type.value, r.customerType) },
{ key: 'isEnable', label: '启用状态', get: r => proxy.selectDictLabel(sys_normal_disable.value, r.isEnable) },
{ key: 'salesUserId', label: '业务员', get: r => r.salesUserNickName || r.salesUser || r.salesUserId || '' },
{ key: 'zq', label: '回款账期', get: r => (r.zq === null || r.zq === undefined ? '' : r.zq) },
{ key: 'settlementUserId', label: '结算员', get: r => getUserName(r.settlementUserId) },
{ key: 'serviceUserId', label: '客服', get: r => getUserName(r.serviceUserId) },
{ key: 'settlementType', label: '结算方式', get: r => proxy.selectDictLabel(settlement_type.value, r.settlementType) },
{ key: 'tempEd', label: '临时额度', get: r => (r.tempEd === null || r.tempEd === undefined ? '' : r.tempEd) },
{ key: 'accountBalance', label: '账户余额(CNY)', get: r => formatBalance(r.accountBalance) },
{ key: 'weightThreshold', label: '重量阈值', get: r => (r.weightThreshold === null || r.weightThreshold === undefined ? '' : r.weightThreshold) },
{ key: 'throwRatio', label: '抛比', get: r => (r.throwRatio === null || r.throwRatio === undefined ? '' : r.throwRatio) },
{ key: 'creditEd', label: '信用额度', get: r => (r.creditEd === null || r.creditEd === undefined ? '' : r.creditEd) },
{ key: 'productCodes', label: '绑定产品', get: r => productNames(r.productCodes) },
{ key: 'priceSetName', label: '绑定套餐', get: r => r.priceSetName || '' },
{ key: 'remark', label: '备注说明', get: r => r.remark || '' }
])
/** 右键菜单只展示当前「列设置」中可见的列 */
const visibleCopyKeys = computed(() =>
columnDefs.filter(c => isColVisible(c.key)).map(c => c.key)
)
function onTableRowContextmenu(row, column, event) {
copyMenuRef.value && copyMenuRef.value.open(row, event)
}
const data = reactive({
queryParams: {
pageNum: 1,
@@ -376,6 +423,7 @@ const { queryParams } = toRefs(data)
/** 查询客户主列表 */
function getList() {
copyMenuRef.value && copyMenuRef.value.close()
loading.value = true
listCustomer(queryParams.value).then(response => {
customerList.value = response.rows
@@ -119,7 +119,9 @@
<column-settings :columns="columnDefs" :visible-map="colVisible" @toggle="toggleColumn" @reset="resetColumns" />
</el-row>
<el-table v-loading="loading" border :data="supplierList" @selection-change="handleSelectionChange">
<el-table v-loading="loading" border :data="supplierList"
@selection-change="handleSelectionChange"
@row-contextmenu="onTableRowContextmenu">
<el-table-column type="selection" width="55" align="center" fixed="left" />
<el-table-column v-if="isColVisible('supplierCode')" label="供应商编码" align="center" width="130" prop="supplierCode" />
<el-table-column v-if="isColVisible('supplierName')" label="供应商名称" align="center" width="130" prop="supplierName" :show-overflow-tooltip="true" />
@@ -189,12 +191,21 @@
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<!-- 右键复制列值 -->
<table-copy-menu
ref="copyMenuRef"
:columns="copyColumns"
:selected-rows="selectedRows"
:visible-keys="visibleCopyKeys"
/>
</div>
</template>
<script setup name="Supplier">
import { listSupplier, delSupplier } from "@/api/customersuppliermanagement/supplierinfo/supplier.js"
import { useChannels, parseChannelCodes } from '@/components/ChannelSelect/useChannels'
import { useUsers } from '@/components/UserSelect/useUsers'
import { useColumnSettings } from '@/utils/columnSettings'
const { proxy } = getCurrentInstance()
@@ -281,6 +292,47 @@ function formatDateOnly(value) {
return s.length > 10 ? s.substring(0, 10) : s
}
/** 员工ID -> 姓名(与表格 UserName 展示同源) */
const { ensureUsers, getUserName } = useUsers()
ensureUsers()
/* ================= 右键复制列值 ================= */
const copyMenuRef = ref(null)
/** 右键可复制列(取值口径与表格展示一致) */
const copyColumns = computed(() => [
{ key: 'supplierCode', label: '供应商编码', get: r => r.supplierCode || '' },
{ key: 'supplierName', label: '供应商名称', get: r => r.supplierName || '' },
{ key: 'supplierCodeDw', label: '对外代码', get: r => r.supplierCodeDw || '' },
{ key: 'creditCode', label: '统一社会信用代码', get: r => r.creditCode || '' },
{ key: 'supplierType', label: '供应商类型', get: r => formatOption(supplierTypeOptions.value, r.supplierType) },
{ key: 'invoiceType', label: '开票类型', get: r => formatOption(invoiceTypeOptions.value, r.invoiceType) },
{ key: 'kacheType', label: '卡车行类型', get: r => formatOption(kacheTypeOptions.value, r.kacheType) },
{ key: 'isEnable', label: '启用状态', get: r => proxy.selectDictLabel(sys_normal_disable.value, r.isEnable) },
{ key: 'purchaserUserId', label: '采购员', get: r => getUserName(r.purchaserUserId) },
{ key: 'ed', label: '应付额度', get: r => (r.ed === null || r.ed === undefined ? '' : r.ed) },
{ key: 'zq', label: '应付账期', get: r => (r.zq === null || r.zq === undefined ? '' : r.zq) },
{ key: 'settlementUserId', label: '结算员', get: r => getUserName(r.settlementUserId) },
{ key: 'serviceUserId', label: '跟单员', get: r => getUserName(r.serviceUserId) },
{ key: 'settlementType', label: '结算方式', get: r => proxy.selectDictLabel(settlement_type.value, r.settlementType) },
{ key: 'tempEd', label: '临时额度', get: r => (r.tempEd === null || r.tempEd === undefined ? '' : r.tempEd) },
{ key: 'accountBalance', label: '应付余额', get: r => (r.accountBalance === null || r.accountBalance === undefined ? '' : r.accountBalance) },
{ key: 'foreignCurrencyBalance', label: '外币应付余额', get: r => (r.foreignCurrencyBalance === null || r.foreignCurrencyBalance === undefined ? '' : r.foreignCurrencyBalance) },
{ key: 'creditEd', label: '信用额度', get: r => (r.creditEd === null || r.creditEd === undefined ? '' : r.creditEd) },
{ key: 'weightThreshold', label: '重量阈值', get: r => (r.weightThreshold === null || r.weightThreshold === undefined ? '' : r.weightThreshold) },
{ key: 'throwRatio', label: '抛比', get: r => (r.throwRatio === null || r.throwRatio === undefined ? '' : r.throwRatio) },
{ key: 'cooperationStartDate', label: '合作开始日期', get: r => formatDateOnly(r.cooperationStartDate) },
{ key: 'cooperationStatus', label: '合作状态', get: r => formatOption(cooperationStatusOptions.value, r.cooperationStatus) },
{ key: 'channelIds', label: '绑定渠道', get: r => channelNames(r.channelIds) },
{ key: 'remark', label: '备注说明', get: r => r.remark || '' }
])
/** 右键菜单只展示当前「列设置」中可见的列 */
const visibleCopyKeys = computed(() =>
columnDefs.filter(c => isColVisible(c.key)).map(c => c.key)
)
function onTableRowContextmenu(row, column, event) {
copyMenuRef.value && copyMenuRef.value.open(row, event)
}
const data = reactive({
queryParams: {
pageNum: 1,
@@ -315,6 +367,7 @@ const { queryParams } = toRefs(data)
/** 查询供应商列表 */
function getList() {
copyMenuRef.value && copyMenuRef.value.close()
loading.value = true
listSupplier(queryParams.value).then(response => {
supplierList.value = response.rows