feat(web-admin): 列表 Excel 查看改为 Univer 只读在线表格
点击工具栏打开可筛选、复制的只读弹层,替换原行列点选复制模式,且不写回业务数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
43e240ad6a
commit
aad74505c0
10 files changed
+1901
-374
No files matched your search
@@ -61,6 +61,9 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"@turbodocx/html-to-docx": "^1.22.2",
|
||||
"@univerjs/preset-sheets-core": "1.4.0",
|
||||
"@univerjs/preset-sheets-filter": "1.4.0",
|
||||
"@univerjs/presets": "1.4.0",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue/reactivity": "^3.5.21",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
@@ -86,6 +89,9 @@
|
||||
"pinia-plugin-persistedstate": "^4.3.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"rxjs": "7.8.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tinymce": "^8.5.1",
|
||||
|
||||
Generated
+1469
-19
File diff suppressed because it is too large.
Load diff
@@ -9,4 +9,5 @@ allowBuilds:
|
||||
core-js: false
|
||||
es5-ext: false
|
||||
esbuild: true
|
||||
protobufjs: false
|
||||
vue-demi: true
|
||||
@@ -1900,19 +1900,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.list-table.is-excel-copy {
|
||||
th.is-excel-col,
|
||||
td.is-excel-rowhead {
|
||||
background: var(--mint-primary-bg) !important;
|
||||
color: var(--mint-primary-dark);
|
||||
}
|
||||
|
||||
td.is-excel-cell {
|
||||
background: rgba(16, 185, 129, 0.16) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--mint-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
|
||||
.page-container .filter-block .el-form-item__label,
|
||||
.console-container .filter-block .el-form-item__label,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<!-- 列表 Excel 只读查看:Univer OSS,可筛选/复制,不可编辑或写回 -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="innerVisible"
|
||||
title="Excel 查看"
|
||||
width="96%"
|
||||
top="2vh"
|
||||
class="dialog-form-layout list-excel-viewer-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@opened="onOpened"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div v-loading="booting" class="list-excel-viewer-body">
|
||||
<p class="list-excel-viewer-hint">只读模式:可筛选、复制;不可编辑或写回系统。</p>
|
||||
<div ref="containerRef" class="list-excel-viewer-host" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
|
||||
import { snapshotToUniverCellData } from '@/utils/list-excel-copy'
|
||||
|
||||
defineOptions({ name: 'ListExcelViewerDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
snapshot: ListExcelSnapshot | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const booting = ref(false)
|
||||
const univerRef = shallowRef<{ dispose: () => void } | null>(null)
|
||||
|
||||
type UniverAPI = {
|
||||
createWorkbook: (data: Record<string, unknown>) => unknown
|
||||
getActiveWorkbook: () => any
|
||||
Enum: {
|
||||
WorkbookPermissionPoint: Record<string, string>
|
||||
WorksheetPermissionPoint: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
function disposeUniver() {
|
||||
try {
|
||||
univerRef.value?.dispose()
|
||||
} catch {
|
||||
// ignore dispose race
|
||||
}
|
||||
univerRef.value = null
|
||||
}
|
||||
|
||||
async function applyReadonlyPermissions(univerAPI: UniverAPI) {
|
||||
const workbook = univerAPI.getActiveWorkbook()
|
||||
if (!workbook) return
|
||||
|
||||
const workbookPermission = workbook.getWorkbookPermission()
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.Edit, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.CopyContent, true)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.CreateSheet, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.DeleteSheet, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.InsertRow, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.InsertColumn, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.DeleteRow, false)
|
||||
await workbookPermission.setPoint(univerAPI.Enum.WorkbookPermissionPoint.DeleteColumn, false)
|
||||
|
||||
const sheet = workbook.getActiveSheet()
|
||||
if (!sheet) return
|
||||
const sheetPermission = sheet.getWorksheetPermission()
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.Edit, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.SetCellValue, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.SetCellStyle, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.InsertRow, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.InsertColumn, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.DeleteRow, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.DeleteColumn, false)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.Filter, true)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.Sort, true)
|
||||
await sheetPermission.setPoint(univerAPI.Enum.WorksheetPermissionPoint.Copy, true)
|
||||
}
|
||||
|
||||
function mountFilter(univerAPI: UniverAPI, snapshot: ListExcelSnapshot) {
|
||||
const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet()
|
||||
if (!sheet) return
|
||||
const colCount = Math.max(snapshot.headers.length, 1)
|
||||
const rowCount = Math.max(snapshot.rows.length + 1, 1)
|
||||
const range = sheet.getRange(0, 0, rowCount, colCount)
|
||||
try {
|
||||
sheet.getFilter()?.remove()
|
||||
} catch {
|
||||
// no existing filter
|
||||
}
|
||||
range.createFilter()
|
||||
}
|
||||
|
||||
async function loadUniverModules() {
|
||||
const [
|
||||
{ UniverSheetsCorePreset },
|
||||
coreZhMod,
|
||||
{ UniverSheetsFilterPreset },
|
||||
filterZhMod,
|
||||
{ createUniver, LocaleType, mergeLocales }
|
||||
] = await Promise.all([
|
||||
import('@univerjs/preset-sheets-core'),
|
||||
import('@univerjs/preset-sheets-core/locales/zh-CN'),
|
||||
import('@univerjs/preset-sheets-filter'),
|
||||
import('@univerjs/preset-sheets-filter/locales/zh-CN'),
|
||||
import('@univerjs/presets'),
|
||||
import('@univerjs/preset-sheets-core/lib/index.css'),
|
||||
import('@univerjs/preset-sheets-filter/lib/index.css')
|
||||
])
|
||||
return {
|
||||
UniverSheetsCorePreset,
|
||||
UniverPresetSheetsCoreZhCN: coreZhMod.default,
|
||||
UniverSheetsFilterPreset,
|
||||
UniverPresetSheetsFilterZhCN: filterZhMod.default,
|
||||
createUniver,
|
||||
LocaleType,
|
||||
mergeLocales
|
||||
}
|
||||
}
|
||||
|
||||
async function bootUniver() {
|
||||
disposeUniver()
|
||||
const snapshot = props.snapshot
|
||||
const container = containerRef.value
|
||||
if (!snapshot || !container) return
|
||||
if (!snapshot.headers.length) {
|
||||
ElMessage.warning('当前列表没有可查看的数据列')
|
||||
return
|
||||
}
|
||||
|
||||
booting.value = true
|
||||
try {
|
||||
const {
|
||||
UniverSheetsCorePreset,
|
||||
UniverPresetSheetsCoreZhCN,
|
||||
UniverSheetsFilterPreset,
|
||||
UniverPresetSheetsFilterZhCN,
|
||||
createUniver,
|
||||
LocaleType,
|
||||
mergeLocales
|
||||
} = await loadUniverModules()
|
||||
|
||||
const cellData = snapshotToUniverCellData(snapshot)
|
||||
const colCount = snapshot.headers.length
|
||||
const rowCount = snapshot.rows.length + 1
|
||||
const sheetId = 'sheet1'
|
||||
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale: LocaleType.ZH_CN,
|
||||
locales: {
|
||||
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN, UniverPresetSheetsFilterZhCN)
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container,
|
||||
toolbar: true,
|
||||
formulaBar: false,
|
||||
footer: false,
|
||||
header: true,
|
||||
contextMenu: true
|
||||
}),
|
||||
UniverSheetsFilterPreset()
|
||||
]
|
||||
})
|
||||
|
||||
univerRef.value = univer
|
||||
const api = univerAPI as unknown as UniverAPI
|
||||
|
||||
api.createWorkbook({
|
||||
id: 'list-excel-viewer',
|
||||
name: '列表数据',
|
||||
sheetOrder: [sheetId],
|
||||
sheets: {
|
||||
[sheetId]: {
|
||||
id: sheetId,
|
||||
name: '当前页',
|
||||
rowCount: Math.max(rowCount + 20, 40),
|
||||
columnCount: Math.max(colCount + 5, 10),
|
||||
cellData
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await applyReadonlyPermissions(api)
|
||||
mountFilter(api, snapshot)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('Excel 查看加载失败,请稍后重试')
|
||||
} finally {
|
||||
booting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpened() {
|
||||
await nextTick()
|
||||
await bootUniver()
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
disposeUniver()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.list-excel-viewer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
min-height: 420px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-excel-viewer-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.list-excel-viewer-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--mint-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.list-excel-viewer-dialog.el-dialog {
|
||||
max-height: calc(100vh - 40px);
|
||||
margin-bottom: 0;
|
||||
|
||||
.el-dialog__body {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<!--
|
||||
主列表薄封装:在 el-table 上启用列宽拖拽,并按 table-key 持久化到 localStorage。
|
||||
用法:将 <el-table class="list-table" …> 换成 <ListTable table-key="crm-customer" class="list-table" …>
|
||||
Excel 查看:工具栏打开只读 Univer 弹层,本组件提供 getExcelSnapshot()。
|
||||
-->
|
||||
<template>
|
||||
<el-table
|
||||
@@ -29,15 +30,11 @@
|
||||
useAttrs,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
buildExcelTsv,
|
||||
excelCell,
|
||||
buildExcelSnapshot,
|
||||
isCopyableColumn,
|
||||
isRowSelectorColumn,
|
||||
resolveCopyAxes,
|
||||
toggleAxis,
|
||||
type ListExcelColumn
|
||||
type ListExcelColumn,
|
||||
type ListExcelSnapshot
|
||||
} from '@/utils/list-excel-copy'
|
||||
import {
|
||||
isFixedNarrowListColumnKey,
|
||||
@@ -55,7 +52,7 @@
|
||||
persistColumnWidth?: boolean
|
||||
/** 是否开启 border(列宽拖拽依赖);默认 true */
|
||||
border?: boolean
|
||||
/** 是否允许 Excel 行列复制,默认 true */
|
||||
/** 是否允许 Excel 只读查看,默认 true */
|
||||
excelCopy?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -71,17 +68,11 @@
|
||||
computed(() => props.tableKey)
|
||||
)
|
||||
|
||||
const excelMode = ref(false)
|
||||
const selectedCols = ref(new Set<number>())
|
||||
const selectedRows = ref(new Set<number>())
|
||||
let colAnchor: number | null = null
|
||||
let rowAnchor: number | null = null
|
||||
|
||||
const resolvedBorder = computed(() => props.border !== false)
|
||||
|
||||
const tableClass = computed(() => {
|
||||
const extra = attrs.class
|
||||
return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
|
||||
return ['list-table', extra]
|
||||
})
|
||||
|
||||
const tableAttrs = computed(() => {
|
||||
@@ -157,14 +148,6 @@
|
||||
}))
|
||||
}
|
||||
|
||||
function copyableColumns(): ListExcelColumn[] {
|
||||
return readColumns().filter((col) => col.id && isCopyableColumn(col))
|
||||
}
|
||||
|
||||
function indexColumn(): ListExcelColumn | undefined {
|
||||
return readColumns().find((col) => col.id && isRowSelectorColumn(col))
|
||||
}
|
||||
|
||||
function tableRoot(): HTMLElement | null {
|
||||
const el = tableRef.value?.$el as HTMLElement | undefined
|
||||
return el ?? null
|
||||
@@ -176,254 +159,45 @@
|
||||
return Array.from(root.querySelectorAll('.el-table__body-wrapper tbody > tr.el-table__row'))
|
||||
}
|
||||
|
||||
function columnFromCell(cell: HTMLElement): ListExcelColumn | undefined {
|
||||
/** 当前可见列 + 当前页行文本,供 Excel 只读弹层使用 */
|
||||
function getExcelSnapshot(): ListExcelSnapshot {
|
||||
if (!props.excelCopy) return { headers: [], rows: [] }
|
||||
const columns = readColumns()
|
||||
const matched = columns.find((col) => col.id && cell.classList.contains(col.id))
|
||||
if (matched) return matched
|
||||
const row = cell.parentElement
|
||||
if (!row) return undefined
|
||||
const cells = Array.from(row.children).filter(
|
||||
(el): el is HTMLTableCellElement => el instanceof HTMLTableCellElement
|
||||
const copyable = columns.filter((col) => col.id && isCopyableColumn(col))
|
||||
const rows = bodyRows()
|
||||
const rowValues = rows.map((row) =>
|
||||
copyable.map((col) => {
|
||||
const td = row.querySelector(`td.${CSS.escape(col.id)}`)
|
||||
return td?.textContent ?? ''
|
||||
})
|
||||
)
|
||||
const visualIndex = cells.indexOf(cell as HTMLTableCellElement)
|
||||
return visualIndex >= 0 ? columns[visualIndex] : undefined
|
||||
// rowValues 已按可复制列对齐,传入过滤后的 columns
|
||||
return buildExcelSnapshot({ columns: copyable, rowValues })
|
||||
}
|
||||
|
||||
function paintExcelSelection() {
|
||||
function bindSnapshotApi() {
|
||||
const root = tableRoot()
|
||||
if (!root) return
|
||||
root.querySelectorAll('.is-excel-col, .is-excel-rowhead, .is-excel-cell').forEach((el) => {
|
||||
el.classList.remove('is-excel-col', 'is-excel-rowhead', 'is-excel-cell')
|
||||
})
|
||||
if (!excelMode.value) return
|
||||
|
||||
const columns = copyableColumns()
|
||||
const rows = bodyRows()
|
||||
const axes = resolveCopyAxes({
|
||||
selectedCols: [...selectedCols.value],
|
||||
selectedRows: [...selectedRows.value],
|
||||
columnCount: columns.length,
|
||||
rowCount: rows.length
|
||||
})
|
||||
const indexCol = indexColumn()
|
||||
|
||||
selectedCols.value.forEach((index) => {
|
||||
const col = columns[index]
|
||||
if (!col) return
|
||||
root.querySelectorAll(`th.${CSS.escape(col.id)}`).forEach((el) => {
|
||||
el.classList.add('is-excel-col')
|
||||
})
|
||||
})
|
||||
const bodies = root.querySelectorAll(
|
||||
'.el-table__body-wrapper tbody, .el-table__fixed-body-wrapper tbody'
|
||||
)
|
||||
if (indexCol) {
|
||||
selectedRows.value.forEach((index) => {
|
||||
bodies.forEach((tbody) => {
|
||||
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[index]
|
||||
row?.querySelector(`td.${CSS.escape(indexCol.id)}`)?.classList.add('is-excel-rowhead')
|
||||
})
|
||||
})
|
||||
}
|
||||
if (!axes) return
|
||||
axes.rows.forEach((rowIndex) => {
|
||||
axes.cols.forEach((colIndex) => {
|
||||
const col = columns[colIndex]
|
||||
if (!col) return
|
||||
bodies.forEach((tbody) => {
|
||||
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[rowIndex]
|
||||
row?.querySelector(`td.${CSS.escape(col.id)}`)?.classList.add('is-excel-cell')
|
||||
})
|
||||
})
|
||||
})
|
||||
;(root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }).__sxGetExcelSnapshot =
|
||||
getExcelSnapshot
|
||||
}
|
||||
|
||||
function clearExcelSelection() {
|
||||
selectedCols.value = new Set()
|
||||
selectedRows.value = new Set()
|
||||
colAnchor = null
|
||||
rowAnchor = null
|
||||
paintExcelSelection()
|
||||
}
|
||||
|
||||
function setExcelMode(next: boolean) {
|
||||
// excelCopy 已关闭时拒绝进入;仍允许关掉,否则 prop 变 false 后无法退出。
|
||||
if (!props.excelCopy && next) return
|
||||
excelMode.value = next
|
||||
if (!next) clearExcelSelection()
|
||||
else paintExcelSelection()
|
||||
tableRoot()?.dispatchEvent(
|
||||
new CustomEvent('sx-list-excel-state', {
|
||||
bubbles: true,
|
||||
detail: { active: excelMode.value }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function clickedRowIndex(row: HTMLTableRowElement): number {
|
||||
const rows = bodyRows()
|
||||
const direct = rows.indexOf(row)
|
||||
if (direct >= 0) return direct
|
||||
const tbody = row.parentElement
|
||||
if (!tbody) return -1
|
||||
const index = Array.from(tbody.querySelectorAll(':scope > tr.el-table__row')).indexOf(row)
|
||||
if (index < 0 || index >= rows.length) return -1
|
||||
return index
|
||||
}
|
||||
|
||||
function keepCopyFocus() {
|
||||
function unbindSnapshotApi() {
|
||||
const root = tableRoot()
|
||||
if (!root) return
|
||||
const panel = root.closest('.panel')
|
||||
const active = document.activeElement
|
||||
if (panel && active instanceof Node && panel.contains(active)) return
|
||||
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1')
|
||||
root.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onTableClick(event: MouseEvent) {
|
||||
if (!excelMode.value || !props.excelCopy) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
const header = target.closest('th')
|
||||
const cell = target.closest('td')
|
||||
if (header) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const columns = copyableColumns()
|
||||
const index = columns.findIndex((col) => header.classList.contains(col.id))
|
||||
if (index < 0) {
|
||||
keepCopyFocus()
|
||||
return
|
||||
}
|
||||
const result = toggleAxis(selectedCols.value, index, event.shiftKey, colAnchor)
|
||||
selectedCols.value = result.selected
|
||||
colAnchor = result.anchor
|
||||
paintExcelSelection()
|
||||
keepCopyFocus()
|
||||
return
|
||||
}
|
||||
if (!cell) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
keepCopyFocus()
|
||||
const row = cell.closest('tr')
|
||||
const column = columnFromCell(cell)
|
||||
// 序号格和数据格都选整行;勾选列、展开列、操作列不选行。
|
||||
if (!row || !column || !(isRowSelectorColumn(column) || isCopyableColumn(column))) return
|
||||
const rowIndex = clickedRowIndex(row)
|
||||
if (rowIndex < 0) return
|
||||
const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
|
||||
selectedRows.value = result.selected
|
||||
rowAnchor = result.anchor
|
||||
paintExcelSelection()
|
||||
}
|
||||
|
||||
function onTableDblClick(event: MouseEvent) {
|
||||
if (!excelMode.value || !props.excelCopy) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target?.closest('td, th')) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function currentTsv(): string {
|
||||
const columns = copyableColumns()
|
||||
const rows = bodyRows()
|
||||
const axes = resolveCopyAxes({
|
||||
selectedCols: [...selectedCols.value],
|
||||
selectedRows: [...selectedRows.value],
|
||||
columnCount: columns.length,
|
||||
rowCount: rows.length
|
||||
})
|
||||
if (!axes) return ''
|
||||
const headers = axes.cols.map((index) => columns[index]?.label ?? '')
|
||||
const matrix = axes.rows.map((rowIndex) =>
|
||||
axes.cols.map((colIndex) => {
|
||||
const col = columns[colIndex]
|
||||
const td = col ? rows[rowIndex]?.querySelector(`td.${CSS.escape(col.id)}`) : null
|
||||
return excelCell(td?.textContent ?? '')
|
||||
})
|
||||
)
|
||||
return buildExcelTsv(headers, matrix)
|
||||
}
|
||||
|
||||
async function copyExcelSelection() {
|
||||
const text = currentTsv()
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }) })
|
||||
])
|
||||
ElMessage.success('已复制,到 Excel 中直接粘贴')
|
||||
} catch {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = text
|
||||
area.setAttribute('readonly', 'true')
|
||||
area.style.position = 'fixed'
|
||||
area.style.left = '-9999px'
|
||||
document.body.appendChild(area)
|
||||
area.select()
|
||||
const ok = document.execCommand('copy')
|
||||
area.remove()
|
||||
if (ok) ElMessage.success('已复制,到 Excel 中直接粘贴')
|
||||
else ElMessage.warning('复制失败,请允许剪贴板权限后重试')
|
||||
}
|
||||
}
|
||||
|
||||
function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const type = (target.getAttribute('type') || 'text').toLowerCase()
|
||||
return type !== 'checkbox' && type !== 'radio'
|
||||
}
|
||||
return target instanceof HTMLElement && target.isContentEditable
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!excelMode.value || !props.excelCopy) return
|
||||
if (isTextEntryTarget(event.target)) return
|
||||
const root = tableRoot()
|
||||
const panel = root?.closest('.panel')
|
||||
const target = event.target as Node | null
|
||||
if (panel && target && !panel.contains(target)) return
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
|
||||
if (!currentTsv()) return
|
||||
event.preventDefault()
|
||||
void copyExcelSelection()
|
||||
}
|
||||
if (event.key === 'Escape') clearExcelSelection()
|
||||
}
|
||||
|
||||
function onExcelToggle() {
|
||||
setExcelMode(!excelMode.value)
|
||||
}
|
||||
|
||||
function bindExcelEvents() {
|
||||
const root = tableRoot()
|
||||
root?.addEventListener('click', onTableClick, true)
|
||||
root?.addEventListener('dblclick', onTableDblClick, true)
|
||||
root?.addEventListener('sx-list-excel-toggle', onExcelToggle)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
|
||||
function unbindExcelEvents() {
|
||||
const root = tableRoot()
|
||||
root?.removeEventListener('click', onTableClick, true)
|
||||
root?.removeEventListener('dblclick', onTableDblClick, true)
|
||||
root?.removeEventListener('sx-list-excel-toggle', onExcelToggle)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
delete (root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot })
|
||||
.__sxGetExcelSnapshot
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scheduleApply()
|
||||
// 首屏列尚未完全注册时再补一次
|
||||
mountApplyTimer = setTimeout(() => scheduleApply(), 100)
|
||||
bindExcelEvents()
|
||||
bindSnapshotApi()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindExcelEvents()
|
||||
unbindSnapshotApi()
|
||||
clearApplyTimers()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
@@ -432,13 +206,13 @@
|
||||
|
||||
onActivated(() => {
|
||||
scheduleApply()
|
||||
bindSnapshotApi()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (attrs as any).data,
|
||||
() => {
|
||||
scheduleApply()
|
||||
if (excelMode.value) clearExcelSelection()
|
||||
},
|
||||
{ deep: false }
|
||||
)
|
||||
@@ -448,7 +222,6 @@
|
||||
() => scheduleApply()
|
||||
)
|
||||
|
||||
// 列显隐导致列数量变化时写回(避免在 drag 过程中用 onUpdated 反复 apply)
|
||||
watch(
|
||||
() => {
|
||||
const cols = tableRef.value?.store?.states?.columns
|
||||
@@ -456,24 +229,6 @@
|
||||
},
|
||||
() => {
|
||||
scheduleApply()
|
||||
if (excelMode.value) clearExcelSelection()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() =>
|
||||
readColumns()
|
||||
.map((col) => col.id)
|
||||
.join('|'),
|
||||
() => {
|
||||
if (excelMode.value) clearExcelSelection()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.excelCopy,
|
||||
(enabled) => {
|
||||
if (!enabled) setExcelMode(false)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -495,13 +250,19 @@
|
||||
resetWidths()
|
||||
}
|
||||
}
|
||||
if (prop === 'getExcelSnapshot') return getExcelSnapshot
|
||||
const table = tableRef.value
|
||||
if (!table) return undefined
|
||||
const value = table[prop as keyof typeof table]
|
||||
return typeof value === 'function' ? value.bind(table) : value
|
||||
},
|
||||
has(_target, prop) {
|
||||
if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
|
||||
if (
|
||||
prop === 'elTableRef' ||
|
||||
prop === 'applyColumnWidths' ||
|
||||
prop === 'resetColumnWidths' ||
|
||||
prop === 'getExcelSnapshot'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const table = tableRef.value
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
<!-- 列表工具栏右侧:刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
|
||||
<!-- 列表工具栏右侧:Excel 查看 + 刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
|
||||
<template>
|
||||
<div ref="rootEl" class="list-toolbar-end">
|
||||
<el-tooltip
|
||||
v-if="showExcelCopy"
|
||||
:content="excelActive ? '退出 Excel 复制' : 'Excel 复制'"
|
||||
placement="top"
|
||||
>
|
||||
<el-tooltip v-if="showExcelCopy" content="Excel 查看" placement="top">
|
||||
<el-button
|
||||
class="list-toolbar-end-btn"
|
||||
:class="{ 'is-excel-active': excelActive }"
|
||||
:aria-pressed="excelActive"
|
||||
aria-label="Excel 复制"
|
||||
@click="toggleExcelCopy"
|
||||
aria-label="Excel 查看"
|
||||
@click="openExcelViewer"
|
||||
>
|
||||
<svg class="list-excel-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
@@ -89,13 +83,25 @@
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<ListExcelViewerDialog
|
||||
v-if="excelSnapshot"
|
||||
v-model="excelViewerVisible"
|
||||
:snapshot="excelSnapshot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Setting } from '@element-plus/icons-vue'
|
||||
import type { ListColumnDef } from '@/composables/usePersistedListColumns'
|
||||
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
|
||||
|
||||
const ListExcelViewerDialog = defineAsyncComponent(
|
||||
() => import('@/components/business/list-excel-viewer-dialog/index.vue')
|
||||
)
|
||||
|
||||
defineOptions({ name: 'ListToolbarEndActions' })
|
||||
|
||||
@@ -154,46 +160,28 @@
|
||||
emit('resetColumns')
|
||||
}
|
||||
|
||||
const excelActive = ref(false)
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
let panelEl: HTMLElement | null = null
|
||||
const excelViewerVisible = ref(false)
|
||||
const excelSnapshot = ref<ListExcelSnapshot | null>(null)
|
||||
|
||||
function controlledTable(): HTMLElement | null {
|
||||
type ExcelTableEl = HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }
|
||||
|
||||
function controlledTable(): ExcelTableEl | null {
|
||||
const panel = rootEl.value?.closest('.panel')
|
||||
const table = panel?.querySelector('[data-list-excel-copy="on"]')
|
||||
return table instanceof HTMLElement ? table : null
|
||||
return table instanceof HTMLElement ? (table as ExcelTableEl) : null
|
||||
}
|
||||
|
||||
function toggleExcelCopy() {
|
||||
controlledTable()?.dispatchEvent(new CustomEvent('sx-list-excel-toggle'))
|
||||
}
|
||||
|
||||
function onExcelState(event: Event) {
|
||||
const custom = event as CustomEvent<{ active?: boolean }>
|
||||
if (event.target !== controlledTable()) return
|
||||
excelActive.value = !!custom.detail?.active
|
||||
}
|
||||
|
||||
function attachExcelStateListener() {
|
||||
const panel = rootEl.value?.closest('.panel')
|
||||
if (!(panel instanceof HTMLElement)) return false
|
||||
panel.addEventListener('sx-list-excel-state', onExcelState, true)
|
||||
panelEl = panel
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!attachExcelStateListener()) {
|
||||
nextTick(() => {
|
||||
attachExcelStateListener()
|
||||
})
|
||||
function openExcelViewer() {
|
||||
const table = controlledTable()
|
||||
const snapshot = table?.__sxGetExcelSnapshot?.()
|
||||
if (!snapshot || !snapshot.headers.length) {
|
||||
ElMessage.warning('当前列表没有可查看的数据列')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
panelEl?.removeEventListener('sx-list-excel-state', onExcelState, true)
|
||||
panelEl = null
|
||||
})
|
||||
excelSnapshot.value = snapshot
|
||||
excelViewerVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -227,26 +215,6 @@
|
||||
border: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.is-excel-active,
|
||||
&.is-excel-active:hover,
|
||||
&.is-excel-active:focus-visible,
|
||||
&.is-excel-active:active {
|
||||
color: #fff !important;
|
||||
background: var(--mint-primary) !important;
|
||||
border: none !important;
|
||||
box-shadow:
|
||||
0 0 0 2px #fff,
|
||||
0 0 0 4px var(--mint-primary) !important;
|
||||
}
|
||||
|
||||
&.is-excel-active:hover,
|
||||
&.is-excel-active:focus-visible {
|
||||
background: var(--mint-primary-dark) !important;
|
||||
box-shadow:
|
||||
0 0 0 2px #fff,
|
||||
0 0 0 4px var(--mint-primary-dark) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.list-excel-icon {
|
||||
@@ -254,8 +222,4 @@
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.is-excel-active .list-excel-icon rect {
|
||||
fill: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
</style>
|
||||
Vendored
+10
@@ -39,6 +39,16 @@ declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' {
|
||||
export default HTMLtoDOCX
|
||||
}
|
||||
|
||||
declare module '@univerjs/preset-sheets-core/locales/zh-CN' {
|
||||
const locale: Record<string, string>
|
||||
export default locale
|
||||
}
|
||||
|
||||
declare module '@univerjs/preset-sheets-filter/locales/zh-CN' {
|
||||
const locale: Record<string, string>
|
||||
export default locale
|
||||
}
|
||||
|
||||
declare module 'qrcode.vue' {
|
||||
export type Level = 'L' | 'M' | 'Q' | 'H'
|
||||
export type RenderAs = 'canvas' | 'svg'
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
buildExcelSnapshot,
|
||||
buildExcelTsv,
|
||||
excelCell,
|
||||
isCopyableColumn,
|
||||
isRowSelectorColumn,
|
||||
resolveCopyAxes,
|
||||
snapshotToUniverCellData,
|
||||
toggleAxis
|
||||
} from './list-excel-copy'
|
||||
|
||||
@@ -135,3 +137,45 @@ describe('buildExcelTsv', () => {
|
||||
assert.equal(tsv, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildExcelSnapshot', () => {
|
||||
it('keeps copyable columns, cleans cells, and drops 操作', () => {
|
||||
const snapshot = buildExcelSnapshot({
|
||||
columns: [
|
||||
{ id: 'name', label: ' 联系人\n' },
|
||||
{ id: 'act', label: '操作' },
|
||||
{ id: 'status', label: '状态' }
|
||||
],
|
||||
rowValues: [
|
||||
['张三\t甲', '编辑', '合作中'],
|
||||
['李四', '删除', '跟进中']
|
||||
]
|
||||
})
|
||||
assert.deepEqual(snapshot.headers, ['联系人', '状态'])
|
||||
assert.deepEqual(snapshot.rows, [
|
||||
['张三 甲', '合作中'],
|
||||
['李四', '跟进中']
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty snapshot when no copyable columns', () => {
|
||||
const snapshot = buildExcelSnapshot({
|
||||
columns: [{ id: 'a', label: '操作' }],
|
||||
rowValues: [['x']]
|
||||
})
|
||||
assert.deepEqual(snapshot, { headers: [], rows: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshotToUniverCellData', () => {
|
||||
it('puts headers on row 0 and data from row 1', () => {
|
||||
const cellData = snapshotToUniverCellData({
|
||||
headers: ['A', 'B'],
|
||||
rows: [['1', '2']]
|
||||
})
|
||||
assert.deepEqual(cellData[0][0], { v: 'A', t: 1 })
|
||||
assert.deepEqual(cellData[0][1], { v: 'B', t: 1 })
|
||||
assert.deepEqual(cellData[1][0], { v: '1', t: 1 })
|
||||
assert.deepEqual(cellData[1][1], { v: '2', t: 1 })
|
||||
})
|
||||
})
|
||||
@@ -68,6 +68,54 @@ export function buildExcelTsv(headers: string[], rows: string[][]): string {
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
/** 灌入只读在线表格的快照:表头 + 当前页文本矩阵 */
|
||||
export type ListExcelSnapshot = {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从可复制列与行文本构建快照。headers/rows 均经 excelCell 清洗。
|
||||
* rowValues 与 columns 按下标对齐;操作列等会被跳过。
|
||||
* 无可复制列时返回 { headers: [], rows: [] }。
|
||||
*/
|
||||
export function buildExcelSnapshot(input: {
|
||||
columns: ListExcelColumn[]
|
||||
/** 与 columns 按下标对齐的行文本;通常来自表格 DOM textContent */
|
||||
rowValues: string[][]
|
||||
}): ListExcelSnapshot {
|
||||
const indices: number[] = []
|
||||
const headers: string[] = []
|
||||
input.columns.forEach((col, index) => {
|
||||
if (!col.id || !isCopyableColumn(col)) return
|
||||
indices.push(index)
|
||||
headers.push(excelCell(col.label))
|
||||
})
|
||||
if (!indices.length) return { headers: [], rows: [] }
|
||||
const rows = input.rowValues.map((row) =>
|
||||
indices.map((colIndex) => excelCell(row[colIndex] ?? ''))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
/** 将快照转为 Univer cellData(第 0 行为表头)。 */
|
||||
export function snapshotToUniverCellData(snapshot: ListExcelSnapshot): Record<
|
||||
number,
|
||||
Record<number, { v: string; t: number }>
|
||||
> {
|
||||
const cellData: Record<number, Record<number, { v: string; t: number }>> = {}
|
||||
const writeRow = (r: number, values: string[]) => {
|
||||
const row: Record<number, { v: string; t: number }> = {}
|
||||
values.forEach((value, c) => {
|
||||
row[c] = { v: value, t: 1 }
|
||||
})
|
||||
cellData[r] = row
|
||||
}
|
||||
writeRow(0, snapshot.headers)
|
||||
snapshot.rows.forEach((row, index) => writeRow(index + 1, row))
|
||||
return cellData
|
||||
}
|
||||
|
||||
function range(count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) => index)
|
||||
}
|
||||
Reference in new issue
Block a user