feat(web-admin): 列表 Excel 查看改为 Univer 只读在线表格

点击工具栏打开可筛选、复制的只读弹层,替换原行列点选复制模式,且不写回业务数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-14 23:25:18 +08:00
1 parent 43e240ad6a
commit aad74505c0
10 files changed
+1901 -374

No files matched your search

+6
View File
@@ -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",
+1469 -19
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,4 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: false
vue-demi: true
-13
View File
@@ -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>
+10
View File
@@ -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 })
})
})
+48
View File
@@ -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)
}