优化交互

This commit is contained in:
李琦
2026-01-22 17:00:24 +08:00
parent 2c2ab1e4f0
commit 0a38130cd9
17 changed files with 2617 additions and 600 deletions

View File

@@ -1,29 +1,47 @@
<script setup>
import { computed } from 'vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
const props = defineProps(['data', 'width', 'height', 'r', 'c'])
const store = useSpreadsheetStore()
// 获取计算后的显示值(处理公式)
const displayValue = computed(() => {
return store.getEvaluatedValue(props.r, props.c)
})
const styleObject = computed(() => {
const s = props.data.style || {}
// 将 align 映射到 flex 布局属性
let justify = 'flex-start'
if (s.align === 'center') justify = 'center'
if (s.align === 'right') justify = 'flex-end'
return {
width: props.width + 'px',
height: props.height + 'px',
fontWeight: s.bold ? 'bold' : 'normal',
fontStyle: s.italic ? 'italic' : 'normal',
textDecoration: s.underline ? 'underline' : 'none',
textAlign: s.align || 'left',
backgroundColor: s.bg || 'transparent',
color: s.color || 'inherit'
color: s.color || 'inherit',
fontFamily: s.fontFamily || 'Arial',
fontSize: (s.fontSize || 11) + 'px',
justifyContent: justify, // 关键Flex 布局下使用 justify 控制水平对齐
}
})
</script>
<template>
<div
class="border-r border-b border-gray-200 text-xs px-1 overflow-hidden whitespace-nowrap flex items-center select-none"
class="border-r border-b border-gray-200 px-1 overflow-hidden whitespace-nowrap flex items-center select-none"
:style="styleObject"
>
<!-- 图片类型 -->
<img v-if="data.type === 'image'" :src="data.value" class="max-w-full max-h-full object-contain pointer-events-none" />
<span v-else>{{ data.value }}</span>
<!-- 文本/公式结果 -->
<span v-else>{{ displayValue }}</span>
</div>
</template>

View File

@@ -0,0 +1,168 @@
<script setup>
import { ref, nextTick, onUnmounted } from 'vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
const props = defineProps(['iconComponent', 'color', 'type']) // type: 'text' | 'fill'
const emit = defineEmits(['change'])
const store = useSpreadsheetStore()
const isOpen = ref(false)
const triggerRef = ref(null)
const dropdownRef = ref(null)
const customColorInput = ref(null) // 新增:自定义颜色输入的引用
const dropdownStyle = ref({ top: '0px', left: '0px' })
// 推荐颜色列表
const recommendedColors = [
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
'#dd7e6b', '#ea9999', '#f9cb9c', '#ffe599', '#b6d7a8', '#a2c4c9', '#a4c2f4', '#9fc5e8', '#b4a7d6', '#d5a6bd',
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
'#a61c00', '#cc0000', '#e69138', '#f1c232', '#6aa84f', '#45818e', '#3c78d8', '#3d85c6', '#674ea7', '#a64d79',
]
// 更新下拉菜单位置
const updatePosition = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
dropdownStyle.value = {
position: 'fixed', // 使用 fixed 定位,相对于视口
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
zIndex: 99999 // 确保层级最高
}
}
}
const toggle = async () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
updatePosition()
// 添加全局点击事件监听,用于点击外部关闭
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
} else {
removeListeners()
}
}
const selectColor = (c) => {
emit('change', c)
// 保持面板打开以便调整颜色,不自动关闭
// close()
}
const close = () => {
isOpen.value = false
removeListeners()
}
// 新增:触发自定义颜色选择
const triggerCustomColor = () => {
customColorInput.value?.click()
}
// 新增:处理自定义颜色变更
const handleCustomColorChange = (e) => {
selectColor(e.target.value)
}
const handleClickOutside = (e) => {
// 如果点击在触发按钮或下拉菜单内部,则不关闭
if (triggerRef.value && triggerRef.value.contains(e.target)) return
if (dropdownRef.value && dropdownRef.value.contains(e.target)) return
close()
}
const removeListeners = () => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
onUnmounted(() => {
removeListeners()
})
</script>
<template>
<div class="relative">
<!-- 触发按钮 -->
<button
ref="triggerRef"
class="p-1.5 rounded hover:bg-gray-200 transition text-gray-700 flex items-center justify-center cursor-pointer relative"
@click.stop="toggle"
:title="type === 'text' ? '字体颜色' : '单元格填充'"
>
<component :is="iconComponent" class="w-4 h-4" :style="{ color: type === 'text' ? (color || '#000') : '#4b5563' }" />
<div class="absolute bottom-1 w-3 h-0.5" :style="{ backgroundColor: type === 'text' ? (color || '#000') : (color || 'transparent') }"></div>
</button>
<!-- 下拉面板 (使用 Teleport 传送到 body) -->
<Teleport to="body">
<div
v-if="isOpen"
ref="dropdownRef"
class="bg-white border border-gray-300 shadow-2xl rounded p-3 w-64"
:style="dropdownStyle"
>
<!-- 历史记录 -->
<div v-if="store.colorHistory.length > 0">
<div class="text-xs text-gray-500 mb-1">最近使用</div>
<div class="flex flex-wrap gap-1 mb-2">
<div
v-for="c in store.colorHistory"
:key="c"
class="w-5 h-5 rounded-sm border border-gray-200 cursor-pointer hover:scale-110 transition-transform shadow-sm"
:style="{ backgroundColor: c }"
@click="selectColor(c)"
></div>
</div>
<div class="h-px bg-gray-200 my-2"></div>
</div>
<!-- 推荐颜色 -->
<div class="text-xs text-gray-500 mb-1">推荐颜色</div>
<div class="grid grid-cols-10 gap-1">
<div
v-for="c in recommendedColors"
:key="c"
class="w-5 h-5 rounded-sm border border-gray-200 cursor-pointer hover:scale-110 transition-transform shadow-sm"
:style="{ backgroundColor: c }"
@click="selectColor(c)"
></div>
</div>
<!-- 更多颜色 & 重置 -->
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1">
<!-- 自定义颜色入口 -->
<button
class="w-full py-1.5 px-2 text-xs text-left hover:bg-gray-100 rounded text-gray-700 flex items-center justify-between"
@click="triggerCustomColor"
>
<span>更多颜色...</span>
<!-- 彩色圆圈示意图 -->
<div class="w-4 h-4 rounded-full border border-gray-300 bg-gradient-to-br from-red-500 via-green-500 to-blue-500"></div>
</button>
<!-- 隐藏的原生 Color Input -->
<!-- @input 改为 @change确保只有在确认选择后才更新避免产生大量历史记录 -->
<input
type="color"
ref="customColorInput"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
@change="handleCustomColorChange"
>
<button class="w-full py-1.5 px-2 text-xs text-left hover:bg-gray-100 rounded text-gray-600" @click="selectColor(null)">
重置颜色
</button>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -1,17 +1,197 @@
<script setup>
defineProps(['value'])
defineEmits(['update'])
import { ref, nextTick, watch } from 'vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
import FormulaSuggestion from './FormulaSuggestion.vue'
const props = defineProps(['value'])
const emit = defineEmits(['update'])
const store = useSpreadsheetStore()
const inputRef = ref(null)
const suggestionRef = ref(null)
// Local state to prevent cursor jumping and manage input
const localValue = ref(props.value || '')
// Sync local value with props (Store -> Component)
watch(() => props.value, (newVal) => {
// Only update if different to avoid cursor reset issues
if (localValue.value !== newVal) {
localValue.value = newVal || ''
}
})
// --- 公式提示状态 ---
const showSuggestions = ref(false)
const suggestionSearch = ref('')
const suggestionRect = ref({ top: 0, left: 0, height: 0 })
// Helper to update store based on current mode
const updateStore = (val) => {
if (store.isEditing) {
// If already in edit mode (triggered from Grid), update editingValue
store.editingValue = val
} else {
// If in Formula Bar mode, update cell directly (avoids showing Grid textarea)
store.updateActiveCell(val)
}
}
// --- fx 按钮逻辑 ---
const handleFxClick = () => {
// If not editing, ensure we are selecting the cell content logic
// We do NOT call store.enableEditing() to keep grid clean
let currentVal = localValue.value
// 如果当前为空,自动填入 "="
if (!currentVal) {
currentVal = '='
updateStore(currentVal)
localValue.value = currentVal
}
// 聚焦并触发建议
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
// 光标移到最后
const len = currentVal.length
inputRef.value.setSelectionRange(len, len)
// 立即触发建议检查
updateSuggestionState()
}
})
}
// --- 建议逻辑 (与 GridEditor 类似) ---
const updateSuggestionState = () => {
if (!inputRef.value) return
const val = localValue.value
const cursor = inputRef.value.selectionStart
// 必须以 = 开头
if (!val.startsWith('=')) {
showSuggestions.value = false
return
}
const textBeforeCursor = val.slice(0, cursor)
// 提取光标前的单词作为搜索词
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
if (match) {
suggestionSearch.value = match[1]
const rect = inputRef.value.getBoundingClientRect()
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
showSuggestions.value = true
} else if (val.trim() === '=') {
// 只有一个 = 号时显示所有
suggestionSearch.value = ''
const rect = inputRef.value.getBoundingClientRect()
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
showSuggestions.value = true
} else {
showSuggestions.value = false
}
}
const handleSuggestionSelect = (funcName) => {
const val = localValue.value
const cursor = inputRef.value.selectionStart
const textBeforeCursor = val.slice(0, cursor)
const textAfterCursor = val.slice(cursor)
let newTextBefore = ''
if (textBeforeCursor.trim() === '=') {
newTextBefore = `=${funcName}(`
} else {
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
if (match) {
newTextBefore = textBeforeCursor.slice(0, match.index) + funcName + '('
} else {
newTextBefore = textBeforeCursor + funcName + '('
}
}
const newVal = newTextBefore + textAfterCursor
localValue.value = newVal
updateStore(newVal)
showSuggestions.value = false
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
const newCursorPos = newTextBefore.length
inputRef.value.setSelectionRange(newCursorPos, newCursorPos)
}
})
}
// 键盘交互
const handleKeyDown = (e) => {
// 1. 如果建议框显示,优先导航建议框
if (showSuggestions.value && suggestionRef.value) {
if (e.key === 'ArrowUp') { e.preventDefault(); suggestionRef.value.navigate('up'); return }
if (e.key === 'ArrowDown') { e.preventDefault(); suggestionRef.value.navigate('down'); return }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); suggestionRef.value.navigate('enter'); return }
if (e.key === 'Escape') { e.preventDefault(); showSuggestions.value = false; return }
}
// 2. 公式栏的回车通常意味着“确认输入”
if (e.key === 'Enter') {
store.finishEditing()
// Optional: Move selection down like Excel
// store.select(store.selection.start.r + 1, store.selection.start.c)
}
}
// 监听输入
const handleInput = (e) => {
localValue.value = e.target.value
updateStore(e.target.value)
updateSuggestionState()
}
</script>
<template>
<div class="h-8 bg-white border-b border-gray-300 flex items-center px-2 shrink-0">
<div class="w-8 text-center text-gray-400 font-serif italic font-bold select-none">fx</div>
<div class="h-8 bg-white border-b border-gray-300 flex items-center px-2 shrink-0 relative z-50">
<!-- fx 按钮 -->
<div
class="w-8 text-center text-gray-400 font-serif italic font-bold select-none cursor-pointer hover:text-green-600 hover:bg-gray-100 rounded"
title="插入函数"
@click="handleFxClick"
>
fx
</div>
<div class="w-px h-4 bg-gray-300 mx-1"></div>
<!-- 输入框 -->
<!-- Use :value prop and @input for manual control to allow localValue sync without cursor jumps -->
<input
ref="inputRef"
class="flex-1 h-full px-2 text-sm outline-none text-gray-700 placeholder-gray-300"
:value="value"
@input="$emit('update', $event.target.value)"
:value="localValue"
@input="handleInput"
@keydown="handleKeyDown"
@blur="() => {
// 延迟 blur 以便点击建议项
setTimeout(() => {
if(!showSuggestions) { /* store.finishEditing() */ }
}, 150)
}"
placeholder="输入值或公式"
>
<!-- 公式提示组件 -->
<FormulaSuggestion
v-if="showSuggestions"
ref="suggestionRef"
:search="suggestionSearch"
:rect="suggestionRect"
@select="handleSuggestionSelect"
@close="showSuggestions = false"
/>
</div>
</template>

View File

@@ -0,0 +1,115 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { FUNCTION_REGISTRY } from '@/utils/formula'
const props = defineProps({
search: { type: String, default: '' },
rect: { type: Object, default: () => ({ top: 0, left: 0, height: 0 }) } // 输入框的位置信息
})
const emit = defineEmits(['select', 'close'])
const selectedIndex = ref(0)
const listRef = ref(null)
// 将注册表转换为数组
const allFunctions = Object.keys(FUNCTION_REGISTRY).map(key => ({
name: key,
...FUNCTION_REGISTRY[key]
}))
// 过滤列表
const filteredList = computed(() => {
const s = props.search.toUpperCase()
// 如果搜索词为空,显示所有;否则模糊匹配
return allFunctions.filter(f => f.name.includes(s)).sort((a, b) => {
// 简单的排序优化:以搜索词开头的排前面
const aStarts = a.name.startsWith(s)
const bStarts = b.name.startsWith(s)
if (aStarts && !bStarts) return -1
if (!aStarts && bStarts) return 1
return a.name.localeCompare(b.name)
})
})
const currentItem = computed(() => filteredList.value[selectedIndex.value])
// 监听搜索词变化,重置选中项
watch(() => props.search, () => {
selectedIndex.value = 0
})
// 暴露给父组件的方法:处理键盘导航
const navigate = (direction) => {
if (direction === 'up') {
selectedIndex.value = Math.max(0, selectedIndex.value - 1)
scrollToItem()
} else if (direction === 'down') {
selectedIndex.value = Math.min(filteredList.value.length - 1, selectedIndex.value + 1)
scrollToItem()
} else if (direction === 'enter' || direction === 'tab') {
if (currentItem.value) {
emit('select', currentItem.value.name)
}
}
}
const scrollToItem = () => {
if (!listRef.value) return
const item = listRef.value.children[selectedIndex.value]
if (item) {
item.scrollIntoView({ block: 'nearest' })
}
}
// 计算位置:显示在输入框下方
const style = computed(() => {
// 简单的定位,实际项目中可能需要检测视口底部溢出
return {
top: (props.rect.top + props.rect.height + 4) + 'px',
left: props.rect.left + 'px',
position: 'fixed',
zIndex: 99999
}
})
// 监听键盘事件的代理
const handleGlobalKeydown = (e) => {
// 这里不直接监听,而是依靠 GridEditor 转发,
// 因为输入框焦点在 GridEditor只有它能捕获 e.preventDefault()
}
defineExpose({ navigate })
</script>
<template>
<Teleport to="body">
<div v-if="filteredList.length > 0" :style="style" class="flex flex-row items-start gap-2 font-sans">
<!-- 函数列表 -->
<div class="bg-white border border-gray-300 shadow-xl rounded-md overflow-hidden w-48 max-h-64 flex flex-col">
<ul ref="listRef" class="overflow-y-auto flex-1 py-1">
<li
v-for="(func, index) in filteredList"
:key="func.name"
class="px-3 py-1.5 cursor-pointer text-xs flex items-center justify-between group"
:class="{'bg-blue-100 text-blue-700': index === selectedIndex, 'hover:bg-gray-100': index !== selectedIndex}"
@click="emit('select', func.name)"
@mouseenter="selectedIndex = index"
>
<span class="font-bold mr-2">{{ func.name }}</span>
<span class="text-[10px] text-gray-400 truncate hidden group-hover:block" :class="{'!text-blue-500 !block': index === selectedIndex}">{{ func.description }}</span>
</li>
</ul>
</div>
<!-- 函数说明提示框 (当前选中的详细信息) -->
<div v-if="currentItem" class="bg-yellow-50 border border-yellow-200 text-yellow-900 shadow-lg rounded p-3 w-64 text-xs">
<div class="font-bold mb-1">{{ currentItem.name }}</div>
<div class="mb-2 text-gray-600">{{ currentItem.description }}</div>
<div class="font-mono bg-white/50 p-1 rounded border border-yellow-100 text-[10px]">{{ currentItem.syntax }}</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -1,14 +1,28 @@
<script setup>
import { ref, reactive, nextTick, watchEffect } from 'vue'
import { ref, reactive, nextTick, watchEffect, computed } from 'vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
import Cell from './Cell.vue'
import SelectionOverlay from './SelectionOverlay.vue'
import ContextMenu from './ContextMenu.vue'
import FormulaSuggestion from './FormulaSuggestion.vue'
import { copyToClipboard, pasteFromClipboard } from '@/utils/clipboard'
import { readSingleFileAsDataURL } from '@/utils/file'
import { ensureRowHeightForImage } from '@/utils/common'
import { resolveCellPosition } from '@/utils/layout'
import { handleGridKeyDown } from '@/utils/keyboard'
const store = useSpreadsheetStore()
const scroller = ref(null)
const contextMenu = reactive({ visible: false, x: 0, y: 0 })
const hiddenFileInput = ref(null)
const editorRef = ref(null)
// --- 公式提示状态 ---
const showSuggestions = ref(false)
const suggestionSearch = ref('')
const suggestionRect = ref({ top: 0, left: 0, height: 0 })
const suggestionRef = ref(null)
const isDragging = ref(false)
const isFilling = ref(false)
@@ -16,210 +30,106 @@ const isFilling = ref(false)
const HEADER_WIDTH = 40
const HEADER_HEIGHT = 32
// --- 辅助方法:自动调整行高 ---
const ensureRowHeightForImage = (rowIndex) => {
const row = store.rows.find(r => r.index === rowIndex)
if (row && row.height < 100) {
row.height = 100
}
}
// --- 输入处理与公式提示逻辑 ---
// --- 剪贴板逻辑 (复制/剪切/粘贴) ---
const performCopy = async (isCut = false) => {
try {
const { minR, maxR, minC, maxC } = store
const jsonGrid = []
let text = ''
let html = '<table border="1">'
for (let r = minR; r <= maxR; r++) {
const rowJson = []
const rowValues = []
html += '<tr>'
for (let c = minC; c <= maxC; c++) {
const cell = store.getCell(r, c)
// 深度拷贝单元格数据
rowJson.push(JSON.parse(JSON.stringify(cell)))
let val = cell.value == null ? '' : String(cell.value)
rowValues.push(val)
// 构建 HTML
let cellHtml = val.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
if (cell.type === 'image') cellHtml = `<img src="${val}" alt="image" style="max-width:100%;" />`
let styleStr = ''
if (cell.style) {
if (cell.style.bold) styleStr += 'font-weight:bold;'
if (cell.style.italic) styleStr += 'font-style:italic;'
if (cell.style.bg) styleStr += `background-color:${cell.style.bg};`
if (cell.style.align) styleStr += `text-align:${cell.style.align};`
}
html += `<td style="${styleStr}">${cellHtml}</td>`
}
jsonGrid.push(rowJson)
text += rowValues.join('\t') + (r < maxR ? '\n' : '')
html += '</tr>'
}
html += '</table>'
// 序列化 JSON 数据并嵌入 HTML 中,实现高保真粘贴
const serializedData = JSON.stringify(jsonGrid)
const wrappedHtml = `<div data-spreadsheet-json='${serializedData}'>${html}</div>`
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([text], { type: 'text/plain' }),
'text/html': new Blob([wrappedHtml], { type: 'text/html' })
})
])
// 如果是剪切只标记状态UI变虚线不立即删除
if (isCut) {
store.setCutSelection()
} else {
store.clearCutSelection() // 普通复制会取消剪切状态
}
} catch (err) {
console.error('剪贴板操作失败', err)
}
}
const performPaste = async () => {
try {
const items = await navigator.clipboard.read()
let pasted = false
for (const item of items) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html')
const html = await blob.text()
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const wrapper = doc.querySelector('[data-spreadsheet-json]')
if (wrapper) {
try {
// 优先尝试高保真 JSON 粘贴
pasteJsonGrid(JSON.parse(wrapper.dataset.spreadsheetJson))
pasted = true
} catch (e) {}
}
if (!pasted) { pasteHtml(html); pasted = true; }
break
}
}
if (!pasted) {
const text = await navigator.clipboard.readText()
pasteText(text)
}
// 粘贴完成后,检查是否有剪切状态。如果有,执行真正的剪切(清除原数据)。
if (store.cutSelection) {
store.executeCut()
}
} catch (err) {
// 降级逻辑:直接读取文本
try {
const text = await navigator.clipboard.readText()
if (text) pasteText(text)
if (store.cutSelection) store.executeCut()
} catch(e) {}
}
}
const pasteJsonGrid = (grid) => {
const startR = store.selection.start.r
const startC = store.selection.start.c
grid.forEach((row, rIndex) => {
row.forEach((cellData, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
store.setCell(targetR, targetC, cellData)
// 如果是图片,自动撑开行高
if (cellData.type === 'image') {
ensureRowHeightForImage(targetR)
}
}
})
})
}
const pasteHtml = (html) => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const trs = doc.querySelectorAll('tr')
if (trs.length === 0) {
const img = doc.querySelector('img')
if(img && img.src) {
store.setCell(store.selection.start.r, store.selection.start.c, { type:'image', value: img.src })
ensureRowHeightForImage(store.selection.start.r)
}
else pasteText(doc.body.textContent)
const updateSuggestionState = () => {
if (!store.isEditing || !editorRef.value) {
showSuggestions.value = false
return
}
const startR = store.selection.start.r
const startC = store.selection.start.c
trs.forEach((tr, rIndex) => {
const tds = tr.querySelectorAll('td, th')
tds.forEach((td, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
const img = td.querySelector('img')
if (img && img.src) {
store.setCell(targetR, targetC, { value: img.src, type: 'image' })
ensureRowHeightForImage(targetR)
}
else store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
}
})
const val = store.editingValue
// 确保使用最新的 DOM 值或 Store 值
const cursor = editorRef.value.selectionStart
// 简单判断:必须以 = 开头
if (!val.startsWith('=')) {
showSuggestions.value = false
return
}
const textBeforeCursor = val.slice(0, cursor)
// 提取光标前的单词
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
if (match) {
const word = match[1]
suggestionSearch.value = word
const rect = editorRef.value.getBoundingClientRect()
suggestionRect.value = {
top: rect.top,
left: rect.left,
height: rect.height
}
showSuggestions.value = true
} else {
// 刚输入完 = 号
if (val.trim() === '=') {
suggestionSearch.value = '' // 显示所有
const rect = editorRef.value.getBoundingClientRect()
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
showSuggestions.value = true
} else {
showSuggestions.value = false
}
}
}
// 处理建议选中
const handleSuggestionSelect = (funcName) => {
const val = store.editingValue
const cursor = editorRef.value.selectionStart
const textBeforeCursor = val.slice(0, cursor)
const textAfterCursor = val.slice(cursor)
let newTextBefore = ''
if (textBeforeCursor.trim() === '=') {
newTextBefore = `=${funcName}(`
} else {
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
if (match) {
newTextBefore = textBeforeCursor.slice(0, match.index) + funcName + '('
} else {
newTextBefore = textBeforeCursor + funcName + '('
}
}
store.editingValue = newTextBefore + textAfterCursor
showSuggestions.value = false
nextTick(() => {
if (editorRef.value) {
editorRef.value.focus()
const newCursorPos = newTextBefore.length
editorRef.value.setSelectionRange(newCursorPos, newCursorPos)
}
})
}
const pasteText = (text) => {
if (!text) return
const rows = text.split(/\r\n|\n|\r/)
const startR = store.selection.start.r
const startC = store.selection.start.c
rows.forEach((rowStr, rIndex) => {
if (rIndex === rows.length - 1 && rowStr === '') return
const cols = rowStr.split('\t')
cols.forEach((val, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
const cleanVal = val.trim()
if (cleanVal.startsWith('data:image/') || /\.(jpeg|jpg|gif|png|webp)/i.test(cleanVal)) {
store.setCell(targetR, targetC, { value: cleanVal, type: 'image' })
ensureRowHeightForImage(targetR)
} else {
store.setCell(targetR, targetC, { value: val, type: 'text' })
}
}
})
})
}
// ------------------------------------
const performCopy = (isCut = false) => copyToClipboard(store, isCut)
const performPaste = () => pasteFromClipboard(store)
// --- 图片上传 ---
const triggerHiddenFileInput = () => hiddenFileInput.value.click()
const handleContextMenuImageUpload = (e) => {
const handleContextMenuImageUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (evt) => {
const base64 = evt.target.result
const r = store.selection.start.r
store.setCell(r, store.selection.start.c, { type: 'image', value: base64 })
ensureRowHeightForImage(r)
}
reader.readAsDataURL(file)
try {
const base64 = await readSingleFileAsDataURL(file)
if (base64) {
const r = store.selection.start.r
store.setCell(r, store.selection.start.c, { type: 'image', value: base64 })
ensureRowHeightForImage(store, r)
}
} catch (err) { console.error(err) }
e.target.value = ''
}
// --- 鼠标交互 ---
const getCellByPoint = (x, y) => {
if (!scroller.value) return { r: -1, c: -1 }
const contentEl = scroller.value.firstElementChild
@@ -227,19 +137,7 @@ const getCellByPoint = (x, y) => {
const rect = contentEl.getBoundingClientRect()
const relX = x - rect.left - HEADER_WIDTH
const relY = y - rect.top - HEADER_HEIGHT
if (relX < 0 || relY < 0) return { r: -1, c: -1 }
let c = 0, currentX = 0
while(c < store.cols.length && currentX + store.cols[c].width < relX) {
currentX += store.cols[c].width
c++
}
let r = 0, currentY = 0
while(r < store.rows.length && currentY + store.rows[r].height < relY) {
currentY += store.rows[r].height
r++
}
return { r: Math.min(r, store.rows.length - 1), c: Math.min(c, store.cols.length - 1) }
return resolveCellPosition(relX, relY, store.rows, store.cols)
}
const handleMouseDown = (e) => {
@@ -247,7 +145,6 @@ const handleMouseDown = (e) => {
contextMenu.visible = false
const { r, c } = getCellByPoint(e.clientX, e.clientY)
if (r === -1 || c === -1) return
if (e.shiftKey) store.extendSelection(r, c)
else store.select(r, c)
isDragging.value = true
@@ -271,14 +168,10 @@ const handleMouseUp = () => {
}
}
// 右键处理
const handleContextMenu = (e) => {
const { r, c } = getCellByPoint(e.clientX, e.clientY)
if (r !== -1 && c !== -1) {
// 如果点击在选区外,则更新选中;选区内则保留选中状态(方便对选区操作)
if (!store.isRowSelected(r) || !store.isColSelected(c)) {
store.select(r, c)
}
if (!store.isRowSelected(r) || !store.isColSelected(c)) store.select(r, c)
}
contextMenu.x = e.clientX
contextMenu.y = e.clientY
@@ -290,7 +183,7 @@ const handleContextAction = (action) => {
const c = store.selection.start.c
switch(action) {
case 'copy': performCopy(); break;
case 'cut': performCopy(true); break; // 触发剪切
case 'cut': performCopy(true); break;
case 'paste': performPaste(); break;
case 'insertImage': triggerHiddenFileInput(); break;
case 'insertRowAbove': store.insertRow(r, 1); break;
@@ -303,71 +196,63 @@ const handleContextAction = (action) => {
}
}
// 快捷键处理
// --- 键盘交互 ---
const handleKeyDown = (e) => {
if (store.isEditing) return
const { r, c } = store.selection.start
const isCtrl = e.ctrlKey || e.metaKey
const key = e.key.toLowerCase()
// 1. 系统级操作
if (isCtrl) {
if (key === 'c') { e.preventDefault(); performCopy(); return }
if (key === 'x') { e.preventDefault(); performCopy(true); return } // 剪切
if (key === 'v') { e.preventDefault(); performPaste(); return }
if (key === 'z') { e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return }
if (key === 'y') { e.preventDefault(); store.redo(); return }
if (key === 'a') { e.preventDefault(); store.selectAll(); return }
// 1. 建议框导航
if (showSuggestions.value && suggestionRef.value) {
if (e.key === 'ArrowUp') {
e.preventDefault()
suggestionRef.value.navigate('up')
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
suggestionRef.value.navigate('down')
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
suggestionRef.value.navigate('enter')
return
}
if (e.key === 'Escape') {
e.preventDefault()
showSuggestions.value = false
return
}
}
// 2. 导航
if (e.key === 'Tab') {
e.preventDefault()
e.shiftKey ? (c > 0 && store.select(r, c - 1)) : (c < store.cols.length - 1 && store.select(r, c + 1))
return
// 2. 编辑模式
if (store.isEditing) {
if (e.key === 'Enter' && !e.shiftKey) {
// 让 Enter 结束编辑
} else {
e.stopPropagation()
return
}
}
// Esc 取消剪切状态
if (e.key === 'Escape') {
e.preventDefault()
store.clearCutSelection()
return
}
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) e.preventDefault()
if (e.key === 'ArrowUp' && r > 0) store.select(r - 1, c)
else if (e.key === 'ArrowDown') store.select(r + 1, c)
else if (e.key === 'ArrowLeft' && c > 0) store.select(r, c - 1)
else if (e.key === 'ArrowRight') store.select(r, c + 1)
// 3. 回车行为
else if (e.key === 'Enter') {
e.preventDefault()
e.shiftKey ? (r > 0 && store.select(r - 1, c)) : store.select(r + 1, c)
}
// 4. 编辑
else if (e.key === 'F2') {
e.preventDefault(); store.enableEditing();
}
else if (e.key === 'Backspace' || e.key === 'Delete') {
store.clearSelection()
}
// 普通输入
else if (e.key.length === 1 && !isCtrl && !e.altKey) {
e.preventDefault()
store.enableEditing()
store.editingValue = e.key
}
// 3. 通用处理
handleGridKeyDown(e, store)
}
// 监听编辑状态变化
watchEffect(() => {
if (store.isEditing) {
nextTick(() => {
const el = document.querySelector('textarea.grid-editor-textarea')
if(el) el.focus()
if (el) {
el.focus()
editorRef.value = el
// 关键修改:手动将光标移到末尾,并立即触发一次建议检查
// 这样当用户直接输入 "=" 激活编辑时,能立即弹出列表
const len = el.value.length
el.setSelectionRange(len, len)
updateSuggestionState()
}
})
} else {
showSuggestions.value = false
}
})
</script>
@@ -376,12 +261,21 @@ watchEffect(() => {
<div class="relative w-full h-full outline-none" tabindex="0" @keydown="handleKeyDown">
<input type="file" ref="hiddenFileInput" class="hidden" accept="image/*" @change="handleContextMenuImageUpload">
<!-- 引入公式建议组件 -->
<FormulaSuggestion
v-if="showSuggestions"
ref="suggestionRef"
:search="suggestionSearch"
:rect="suggestionRect"
@select="handleSuggestionSelect"
@close="showSuggestions = false"
/>
<div class="w-full h-full overflow-auto bg-[#f4f5f7] relative custom-scrollbar" ref="scroller">
<div class="relative bg-white m-8 shadow-sm select-none"
:style="{ width: store.totalWidth + 'px', height: store.totalHeight + 'px' }"
@contextmenu.prevent="handleContextMenu"
>
<!-- 列头 -->
<div class="sticky top-[-32px] left-0 right-0 z-20 flex h-8 bg-[#f4f5f7] border-b border-gray-300">
<div class="w-10 bg-[#f4f5f7] border-r border-gray-300 flex-shrink-0 cursor-pointer hover:bg-gray-200" @click="store.selectAll"></div>
<div v-for="(col, i) in store.cols" :key="i"
@@ -393,7 +287,6 @@ watchEffect(() => {
</div>
<div class="flex">
<!-- 行头 -->
<div class="sticky left-0 z-10 flex flex-col w-10 bg-[#f4f5f7] border-r border-gray-300 flex-shrink-0">
<div v-for="(row, i) in store.rows" :key="i"
class="flex items-center justify-center border-b border-gray-200 text-xs text-gray-500 bg-[#f4f5f7] hover:bg-gray-200 relative"
@@ -403,7 +296,6 @@ watchEffect(() => {
</div>
</div>
<!-- 单元格区域 -->
<div class="relative"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@@ -432,10 +324,16 @@ watchEffect(() => {
<textarea
v-if="store.isEditing"
v-model="store.editingValue"
ref="editorRef"
class="grid-editor-textarea absolute z-30 text-xs px-1 border-2 border-green-500 shadow-xl outline-none resize-none bg-white leading-normal overflow-hidden"
:style="store.editorStyle"
@blur="store.finishEditing"
@keydown.enter.prevent="store.finishEditing"
@input="updateSuggestionState"
@blur="() => {
setTimeout(() => {
if (!showSuggestions) store.finishEditing()
}, 150)
}"
@keydown.stop="handleKeyDown"
></textarea>
</div>

View File

@@ -1,9 +1,14 @@
<script setup>
import { ref } from 'vue'
import {
Undo2, Redo2, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Image as ImageIcon, Plus
Undo2, Redo2, PaintRoller,
Bold, Italic, Underline, Strikethrough,
AlignLeft, AlignCenter, AlignRight,
Image as ImageIcon, Plus,
Type, Palette, ChevronDown
} from 'lucide-vue-next'
import { readFilesAsDataURL } from '@/utils/file'
import ColorPicker from './ColorPicker.vue'
const props = defineProps(['currentStyle'])
const emit = defineEmits(['action'])
@@ -12,81 +17,123 @@ const fileInput = ref(null)
const triggerImageUpload = () => fileInput.value.click()
// 支持多图上传的处理逻辑
const handleImageUpload = async (e) => {
const files = Array.from(e.target.files)
if (files.length === 0) return
// 并发读取所有图片
const readers = files.map(file => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (evt) => resolve(evt.target.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
})
try {
const images = await Promise.all(readers)
// 触发批量插入事件
emit('action', 'insertImages', images)
const images = await readFilesAsDataURL(e.target.files)
if (images.length > 0) {
emit('action', 'insertImages', images)
}
} catch (error) {
console.error("图片读取失败", error)
}
// 重置 input 以便能重复上传同一文件
e.target.value = ''
}
const btnClass = "p-1.5 rounded hover:bg-gray-200 transition text-gray-600 flex items-center justify-center cursor-pointer"
const handleColorChange = (type, color) => {
emit('action', 'setStyle', { [type]: color })
}
const handleFontChange = (e) => emit('action', 'setStyle', { fontFamily: e.target.value })
const handleFontSizeChange = (e) => emit('action', 'setStyle', { fontSize: e.target.value })
const btnClass = "p-1.5 rounded hover:bg-gray-200 transition text-gray-700 flex items-center justify-center cursor-pointer relative"
const activeClass = "bg-gray-300 text-black"
const dividerClass = "w-px h-5 bg-gray-300 mx-1 self-center"
</script>
<template>
<div class="h-10 bg-[#fbfbfb] border-b border-gray-300 flex items-center px-2 gap-2 select-none shrink-0">
<div class="flex gap-1 pr-2 border-r border-gray-300">
<button :class="btnClass" title="撤销"><Undo2 class="w-4 h-4" /></button>
<button :class="btnClass" title="重做"><Redo2 class="w-4 h-4" /></button>
<div class="h-10 bg-[#fbfbfb] border-b border-gray-300 flex items-center px-3 gap-1 select-none shrink-0 overflow-x-auto whitespace-nowrap z-40">
<!-- 历史 -->
<div class="flex gap-0.5">
<button :class="btnClass" title="撤销 (Ctrl+Z)" @click="$emit('action', 'undo')"><Undo2 class="w-4 h-4" /></button>
<button :class="btnClass" title="重做 (Ctrl+Y)" @click="$emit('action', 'redo')"><Redo2 class="w-4 h-4" /></button>
</div>
<div class="flex gap-1 pr-2 border-r border-gray-300">
<button :class="[btnClass, currentStyle.bold ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'bold')">
<Bold class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.italic ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'italic')">
<Italic class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.underline ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'underline')">
<Underline class="w-4 h-4" />
</button>
<div :class="dividerClass"></div>
<div class="w-px h-4 bg-gray-300 mx-1 self-center"></div>
<!-- 字体与字号 -->
<div class="flex gap-1 items-center">
<div class="relative group">
<select
class="appearance-none bg-transparent hover:bg-gray-200 pl-2 pr-6 py-1 rounded text-xs text-gray-700 outline-none cursor-pointer w-24 border border-transparent hover:border-gray-300 transition-colors"
:value="currentStyle.fontFamily || 'Arial'"
@change="handleFontChange"
>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Verdana">Verdana</option>
</select>
<ChevronDown class="w-3 h-3 absolute right-1 top-1.5 text-gray-500 pointer-events-none" />
</div>
<button :class="[btnClass, currentStyle.align === 'left' ? activeClass : '']" @click="$emit('action', 'setAlign', 'left')">
<AlignLeft class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.align === 'center' ? activeClass : '']" @click="$emit('action', 'setAlign', 'center')">
<AlignCenter class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.align === 'right' ? activeClass : '']" @click="$emit('action', 'setAlign', 'right')">
<AlignRight class="w-4 h-4" />
</button>
<div class="relative group">
<select
class="appearance-none bg-transparent hover:bg-gray-200 pl-2 pr-5 py-1 rounded text-xs text-gray-700 outline-none cursor-pointer w-14 border border-transparent hover:border-gray-300 transition-colors"
:value="currentStyle.fontSize || 11"
@change="handleFontSizeChange"
>
<option v-for="size in [9,10,11,12,14,18,24,36]" :key="size" :value="size">{{ size }}</option>
</select>
<ChevronDown class="w-3 h-3 absolute right-1 top-1.5 text-gray-500 pointer-events-none" />
</div>
</div>
<div class="flex gap-1 pr-2 border-r border-gray-300">
<div :class="dividerClass"></div>
<!-- 样式 -->
<div class="flex gap-0.5">
<button :class="[btnClass, currentStyle.bold ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'bold')"><Bold class="w-4 h-4" /></button>
<button :class="[btnClass, currentStyle.italic ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'italic')"><Italic class="w-4 h-4" /></button>
<button :class="[btnClass, currentStyle.underline ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'underline')"><Underline class="w-4 h-4" /></button>
<button :class="[btnClass, currentStyle.strike ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'strike')"><Strikethrough class="w-4 h-4" /></button>
</div>
<div :class="dividerClass"></div>
<!-- 颜色选择 (使用新组件) -->
<div class="flex gap-0.5">
<ColorPicker
type="text"
:icon-component="Type"
:color="currentStyle.color"
@change="(c) => handleColorChange('color', c)"
/>
<ColorPicker
type="fill"
:icon-component="Palette"
:color="currentStyle.bg"
@change="(c) => handleColorChange('bg', c)"
/>
</div>
<div :class="dividerClass"></div>
<!-- 对齐方式 (修复状态回显确保 Cell.vue 中使用了 flex justify 来实现对齐store 中存储的是 'left'/'center'/'right') -->
<div class="flex gap-0.5">
<button :class="[btnClass, (!currentStyle.align || currentStyle.align === 'left') ? activeClass : '']" @click="$emit('action', 'setAlign', 'left')"><AlignLeft class="w-4 h-4" /></button>
<button :class="[btnClass, currentStyle.align === 'center' ? activeClass : '']" @click="$emit('action', 'setAlign', 'center')"><AlignCenter class="w-4 h-4" /></button>
<button :class="[btnClass, currentStyle.align === 'right' ? activeClass : '']" @click="$emit('action', 'setAlign', 'right')"><AlignRight class="w-4 h-4" /></button>
</div>
<div :class="dividerClass"></div>
<!-- 插入 -->
<div class="flex gap-0.5">
<button :class="btnClass" title="插入图片" @click="triggerImageUpload">
<ImageIcon class="w-4 h-4" />
</button>
<!-- 关键修改添加 multiple 属性支持多选 -->
<input type="file" ref="fileInput" class="hidden" accept="image/*" multiple @change="handleImageUpload">
</div>
<div :class="dividerClass"></div>
<div class="flex gap-1">
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700" @click="$emit('action', 'addRow')">
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700 transition" @click="$emit('action', 'addRow')">
<Plus class="w-3 h-3" />
</button>
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700" @click="$emit('action', 'addCol')">
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700 transition" @click="$emit('action', 'addCol')">
<Plus class="w-3 h-3" />
</button>
</div>

View File

@@ -11,6 +11,13 @@ const routes = [
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
]
},
{
path: '/excel',
component: MainLayout,
children: [
{ path: '', name: 'Excel', component: () => import('@/views/excel/index.vue') },
]
},
]
const router = createRouter({

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed, reactive, watch } from 'vue'
import { db } from '@/utils/db'
import { evaluateFormula } from '@/utils/formula'
export const useSpreadsheetStore = defineStore('spreadsheet', () => {
// --- State ---
@@ -8,24 +9,24 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
const cols = ref(Array.from({ length: 26 }, (_, i) => ({ label: String.fromCharCode(65 + i), width: 100 })))
const cells = reactive({})
const selection = reactive({ start: { r: 1, c: 1 }, end: { r: 1, c: 1 } })
// New: Track cut selection (source of the cut)
const cutSelection = ref(null)
const isEditing = ref(false)
const editingValue = ref('')
const loading = ref(true)
// --- History State (Undo/Redo) ---
// 颜色历史记录 (最近使用的颜色)
const colorHistory = ref([])
// --- History State ---
const history = ref([])
const historyIndex = ref(-1)
const isUndoing = ref(false)
// --- Helpers ---
const SHEET_KEY = 'default_sheet_v1'
const getKey = (r, c) => `${r}_${c}`
// --- Persistence ---
const SHEET_KEY = 'default_sheet_v1'
// --- Data Loading ---
const loadData = async () => {
loading.value = true
try {
@@ -35,6 +36,7 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
Object.assign(cells, data.cells)
if (data.rows) rows.value = data.rows
if (data.cols) cols.value = data.cols
if (data.colorHistory) colorHistory.value = data.colorHistory // 加载历史颜色
saveSnapshot()
} else {
saveSnapshot()
@@ -46,115 +48,119 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
}
}
// --- Snapshot Logic ---
// --- Formula & Value Helpers ---
// 获取原始数据对象
const getRawCell = (r, c) => cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
// 获取计算后的值 (供显示用)
// useRecursionStack 防止循环引用 (简单处理)
const recursionStack = new Set()
const getEvaluatedValue = (r, c) => {
const key = getKey(r, c)
if (recursionStack.has(key)) return "#CYCLE!"
const cell = getRawCell(r, c)
const val = cell.value
// 如果不是公式,直接返回
if (typeof val !== 'string' || !val.startsWith('=')) {
return val
}
recursionStack.add(key)
try {
// 递归求解
const result = evaluateFormula(val, (targetR, targetC) => {
return getEvaluatedValue(targetR, targetC)
})
return result
} finally {
recursionStack.delete(key)
}
}
// --- Snapshot & Save ---
const saveSnapshot = (opType = 'mutation') => {
if (isUndoing.value) return
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
const snapshot = {
cells: JSON.parse(JSON.stringify(cells)),
rows: JSON.parse(JSON.stringify(rows.value)),
cols: JSON.parse(JSON.stringify(cols.value))
}
history.value.push(snapshot)
historyIndex.value++
if (history.value.length > 50) { history.value.shift(); historyIndex.value-- }
if (history.value.length > 50) {
history.value.shift()
historyIndex.value--
}
// Record operation to IndexedDB Log
db.addLog(opType, {
selection: JSON.parse(JSON.stringify(selection)),
timestamp: Date.now()
})
// 记录日志略...
}
const restoreSnapshot = (snapshot) => {
for (const key in cells) delete cells[key]
Object.assign(cells, snapshot.cells)
rows.value = snapshot.rows
cols.value = snapshot.cols
}
const undo = () => {
if (historyIndex.value > 0) {
isUndoing.value = true
historyIndex.value--
const prevSnapshot = history.value[historyIndex.value]
restoreSnapshot(prevSnapshot)
isUndoing.value = false
}
}
const redo = () => {
if (historyIndex.value < history.value.length - 1) {
isUndoing.value = true
historyIndex.value++
const nextSnapshot = history.value[historyIndex.value]
restoreSnapshot(nextSnapshot)
isUndoing.value = false
}
}
let snapshotTimeout = null
watch([cells, rows, cols], () => {
watch([cells, rows, cols, colorHistory], () => {
if (loading.value || isUndoing.value) return
clearTimeout(snapshotTimeout)
snapshotTimeout = setTimeout(() => {
setTimeout(() => {
saveSnapshot('auto_save')
db.set(SHEET_KEY, { cells, rows: rows.value, cols: cols.value })
db.set(SHEET_KEY, {
cells, rows: rows.value, cols: cols.value,
colorHistory: colorHistory.value
})
}, 500)
}, { deep: true })
// --- Getters ---
// --- Actions ---
// 添加颜色到历史记录 (去重保持最近10个)
const addToColorHistory = (color) => {
if (!color) return
const list = colorHistory.value.filter(c => c !== color)
list.unshift(color)
if (list.length > 10) list.pop()
colorHistory.value = list
}
// 基础操作... (省略部分未变代码以节省篇幅,重点看下面的 getCell 逻辑)
const minR = computed(() => Math.min(selection.start.r, selection.end.r))
const maxR = computed(() => Math.max(selection.start.r, selection.end.r))
const minC = computed(() => Math.min(selection.start.c, selection.end.c))
const maxC = computed(() => Math.max(selection.start.c, selection.end.c))
const totalWidth = computed(() => cols.value.reduce((acc, c) => acc + c.width, 0))
const totalHeight = computed(() => rows.value.reduce((acc, r) => acc + r.height, 0))
const getCell = (r, c) => cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
const currentStyle = computed(() => getCell(selection.start.r, selection.start.c).style || {})
const currentStyle = computed(() => getRawCell(selection.start.r, selection.start.c).style || {})
// 公式栏显示原始值 (公式)
const activeCellFormula = computed(() => {
if (isEditing.value) return editingValue.value
const cell = getCell(selection.start.r, selection.start.c)
const cell = getRawCell(selection.start.r, selection.start.c)
return String(cell.value || '')
})
// Calculate layout info for selection overlay
// 布局计算...
const layoutInfo = computed(() => {
const getRowTop = (r) => rows.value.slice(0, r).reduce((sum, row) => sum + row.height, 0)
const getColLeft = (c) => cols.value.slice(0, c).reduce((sum, col) => sum + col.width, 0)
const top = getRowTop(minR.value)
const left = getColLeft(minC.value)
let height = 0; for(let i=minR.value; i<=maxR.value; i++) height += rows.value[i].height
let width = 0; for(let i=minC.value; i<=maxC.value; i++) width += cols.value[i].width
return { top, left, width, height }
return {
top: getRowTop(minR.value),
left: getColLeft(minC.value),
width: cols.value.slice(minC.value, maxC.value + 1).reduce((acc, c) => acc + c.width, 0),
height: rows.value.slice(minR.value, maxR.value + 1).reduce((acc, r) => acc + r.height, 0)
}
})
// Calculate layout info for CUT overlay (new)
const cutLayoutInfo = computed(() => {
if (!cutSelection.value) return null
const r1 = Math.min(cutSelection.value.start.r, cutSelection.value.end.r)
const r2 = Math.max(cutSelection.value.start.r, cutSelection.value.end.r)
const c1 = Math.min(cutSelection.value.start.c, cutSelection.value.end.c)
const c2 = Math.max(cutSelection.value.start.c, cutSelection.value.end.c)
const getRowTop = (r) => rows.value.slice(0, r).reduce((sum, row) => sum + row.height, 0)
const getColLeft = (c) => cols.value.slice(0, c).reduce((sum, col) => sum + col.width, 0)
return {
top: getRowTop(r1),
left: getColLeft(c1),
top: getRowTop(r1), left: getColLeft(c1),
width: cols.value.slice(c1, c2 + 1).reduce((acc, c) => acc + c.width, 0),
height: rows.value.slice(r1, r2 + 1).reduce((acc, r) => acc + r.height, 0)
}
@@ -163,11 +169,9 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
const editorStyle = computed(() => {
let sTop = 0; for(let i=0; i<selection.start.r; i++) sTop += rows.value[i].height
let sLeft = 0; for(let i=0; i<selection.start.c; i++) sLeft += cols.value[i].width
const w = cols.value[selection.start.c].width
const h = rows.value[selection.start.r].height
const style = getCell(selection.start.r, selection.start.c).style || {}
const style = getRawCell(selection.start.r, selection.start.c).style || {}
return {
top: sTop + 'px', left: sLeft + 'px',
minWidth: w + 'px', minHeight: h + 'px',
@@ -178,7 +182,7 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
}
})
// --- Actions ---
// --- Setter Wrapper ---
function setCell(r, c, partialData) {
const key = getKey(r, c)
if (!cells[key]) cells[key] = { type: 'text', value: '', style: {} }
@@ -187,51 +191,19 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
if (partialData.type !== undefined) cells[key].type = partialData.type
}
function select(r, c) {
if (isEditing.value) return
selection.start = { r, c }
selection.end = { r, c }
}
function extendSelection(r, c) {
if (isEditing.value) return
selection.end = { r, c }
}
function selectAll() {
selection.start = { r: 0, c: 0 }
selection.end = { r: rows.value.length - 1, c: cols.value.length - 1 }
}
// --- Standard Actions ---
function select(r, c) { if (isEditing.value) return; selection.start = { r, c }; selection.end = { r, c } }
function extendSelection(r, c) { if (isEditing.value) return; selection.end = { r, c } }
function selectAll() { selection.start = { r: 0, c: 0 }; selection.end = { r: rows.value.length - 1, c: cols.value.length - 1 } }
function enableEditing() { isEditing.value = true; const cell = getRawCell(selection.start.r, selection.start.c); editingValue.value = String(cell.value || '') }
function finishEditing() { if (!isEditing.value) return; setCell(selection.start.r, selection.start.c, { value: editingValue.value }); isEditing.value = false }
function updateActiveCell(val) { setCell(selection.start.r, selection.start.c, { value: val }) }
function forEachSelectedCell(cb) { for (let r = minR.value; r <= maxR.value; r++) { for (let c = minC.value; c <= maxC.value; c++) { cb(r, c) } } }
function enableEditing() {
isEditing.value = true
const cell = getCell(selection.start.r, selection.start.c)
editingValue.value = String(cell.value || '')
}
function finishEditing() {
if (!isEditing.value) return
setCell(selection.start.r, selection.start.c, { value: editingValue.value })
isEditing.value = false
}
function updateActiveCell(val) {
setCell(selection.start.r, selection.start.c, { value: val })
}
function toggleStyle(prop) { const current = !!currentStyle.value[prop]; forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: !current } })) }
function setStyle(prop, value) { forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: value } })) }
function forEachSelectedCell(cb) {
for (let r = minR.value; r <= maxR.value; r++) {
for (let c = minC.value; c <= maxC.value; c++) {
cb(r, c)
}
}
}
function toggleStyle(prop) {
const current = !!currentStyle.value[prop]
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: !current } }))
}
function setStyle(prop, value) {
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: value } }))
}
// --- Data Shifting ---
// --- Data Shifting & Structure ---
const shiftCells = (type, index, count, direction) => {
const newCells = {}
Object.entries(cells).forEach(([key, cell]) => {
@@ -246,100 +218,40 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
}
if (!shouldDelete) newCells[`${newR}_${newC}`] = cell
})
for (const key in cells) delete cells[key]; Object.assign(cells, newCells)
}
function insertRow(index, count = 1) { saveSnapshot(); for(let k=0; k<count; k++) rows.value.splice(index, 0, { index: -1, height: 25 }); rows.value.forEach((r, i) => r.index = i); shiftCells('row', index, count, 1) }
function deleteRow(index, count = 1) { saveSnapshot(); rows.value.splice(index, count); rows.value.forEach((r, i) => r.index = i); shiftCells('row', index, count, -1) }
function insertCol(index, count = 1) { saveSnapshot(); for(let k=0; k<count; k++) cols.value.splice(index, 0, { label: 'New', width: 100 }); cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26))); shiftCells('col', index, count, 1) }
function deleteCol(index, count = 1) { saveSnapshot(); cols.value.splice(index, count); cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26))); shiftCells('col', index, count, -1) }
function setCutSelection() { cutSelection.value = JSON.parse(JSON.stringify(selection)) }
function clearCutSelection() { cutSelection.value = null }
function clearSelection() { forEachSelectedCell((r, c) => setCell(r, c, { value: '', type: 'text' })) }
function executeCut() { if (!cutSelection.value) return; const r1 = Math.min(cutSelection.value.start.r, cutSelection.value.end.r); const r2 = Math.max(cutSelection.value.start.r, cutSelection.value.end.r); const c1 = Math.min(cutSelection.value.start.c, cutSelection.value.end.c); const c2 = Math.max(cutSelection.value.start.c, cutSelection.value.end.c); for (let r = r1; r <= r2; r++) { for (let c = c1; c <= c2; c++) { setCell(r, c, { value: '', type: 'text' }) } } cutSelection.value = null }
function autoFill(targetEndR, targetEndC) { saveSnapshot(); const srcR = selection.start.r; const srcC = selection.start.c; const template = getRawCell(srcR, srcC); for(let r=minR.value; r<=targetEndR; r++) { for(let c=minC.value; c<=targetEndC; c++) { if (r <= maxR.value && c <= maxC.value) continue; setCell(r, c, JSON.parse(JSON.stringify(template))) } } selection.end = { r: targetEndR, c: targetEndC } }
// 恢复历史记录快照
const restoreSnapshot = (snapshot) => {
for (const key in cells) delete cells[key]
Object.assign(cells, newCells)
Object.assign(cells, snapshot.cells)
rows.value = snapshot.rows
cols.value = snapshot.cols
}
function insertRow(index, count = 1) {
saveSnapshot('insert_row')
for(let k=0; k<count; k++) rows.value.splice(index, 0, { index: -1, height: 25 })
rows.value.forEach((r, i) => r.index = i)
shiftCells('row', index, count, 1)
}
function deleteRow(index, count = 1) {
saveSnapshot('delete_row')
rows.value.splice(index, count)
rows.value.forEach((r, i) => r.index = i)
shiftCells('row', index, count, -1)
}
function insertCol(index, count = 1) {
saveSnapshot('insert_col')
for(let k=0; k<count; k++) cols.value.splice(index, 0, { label: 'New', width: 100 })
cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26)))
shiftCells('col', index, count, 1)
}
function deleteCol(index, count = 1) {
saveSnapshot('delete_col')
cols.value.splice(index, count)
cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26)))
shiftCells('col', index, count, -1)
}
// --- Cut/Paste/Clear ---
function setCutSelection() {
// Deep copy current selection to be the cut source
cutSelection.value = JSON.parse(JSON.stringify(selection))
}
function clearCutSelection() {
cutSelection.value = null
}
// Used by "Delete" key or "Clear" menu
function clearSelection() {
forEachSelectedCell((r, c) => setCell(r, c, { value: '', type: 'text' }))
}
// Execute the "Cut" action: Clear the source cells (Called AFTER paste)
function executeCut() {
if (!cutSelection.value) return
const r1 = Math.min(cutSelection.value.start.r, cutSelection.value.end.r)
const r2 = Math.max(cutSelection.value.start.r, cutSelection.value.end.r)
const c1 = Math.min(cutSelection.value.start.c, cutSelection.value.end.c)
const c2 = Math.max(cutSelection.value.start.c, cutSelection.value.end.c)
for (let r = r1; r <= r2; r++) {
for (let c = c1; c <= c2; c++) {
setCell(r, c, { value: '', type: 'text' })
}
}
cutSelection.value = null
}
function autoFill(targetEndR, targetEndC) {
saveSnapshot('autofill')
const srcR = selection.start.r
const srcC = selection.start.c
const template = getCell(srcR, srcC)
for(let r=minR.value; r<=targetEndR; r++) {
for(let c=minC.value; c<=targetEndC; c++) {
if (r <= maxR.value && c <= maxC.value) continue
setCell(r, c, JSON.parse(JSON.stringify(template)))
}
}
selection.end = { r: targetEndR, c: targetEndC }
}
function isColSelected(i) { return i >= minC.value && i <= maxC.value }
function isRowSelected(i) { return i >= minR.value && i <= maxR.value }
const undo = () => { if (historyIndex.value > 0) { isUndoing.value = true; historyIndex.value--; restoreSnapshot(history.value[historyIndex.value]); isUndoing.value = false } }
const redo = () => { if (historyIndex.value < history.value.length - 1) { isUndoing.value = true; historyIndex.value++; restoreSnapshot(history.value[historyIndex.value]); isUndoing.value = false } }
return {
rows, cols, cells, selection, cutSelection, // State
isEditing, editingValue, loading,
rows, cols, cells, selection, cutSelection, isEditing, editingValue, loading,
minR, maxR, minC, maxC, totalWidth, totalHeight,
getCell, currentStyle, activeCellFormula, layoutInfo, cutLayoutInfo, editorStyle,
isColSelected, isRowSelected,
loadData, select, extendSelection, selectAll,
enableEditing, finishEditing, updateActiveCell, setCell,
toggleStyle, setStyle,
insertRow, insertCol, deleteRow, deleteCol,
setCutSelection, clearCutSelection, executeCut, clearSelection,
autoFill, forEachSelectedCell,
undo, redo
getCell: getRawCell, // 提供给内部复制粘贴用
getEvaluatedValue, // 提供给 Grid 显示用
currentStyle, activeCellFormula, layoutInfo, cutLayoutInfo, editorStyle, colorHistory,
isColSelected: (i) => i >= minC.value && i <= maxC.value,
isRowSelected: (i) => i >= minR.value && i <= maxR.value,
loadData, select, extendSelection, selectAll, enableEditing, finishEditing, updateActiveCell, setCell,
toggleStyle, setStyle, insertRow, insertCol, deleteRow, deleteCol, setCutSelection, clearCutSelection, executeCut, clearSelection, autoFill, forEachSelectedCell, undo, redo, addToColorHistory
}
})

207
src/utils/clipboard.js Normal file
View File

@@ -0,0 +1,207 @@
import { isImageUrl, ensureRowHeightForImage } from './common'
/**
* 执行复制操作
* @param {Object} store Pinia Store 实例
* @param {Boolean} isCut 是否为剪切模式
*/
export const copyToClipboard = async (store, isCut = false) => {
try {
const { minR, maxR, minC, maxC } = store
const jsonGrid = []
let text = ''
let html = '<table border="1">'
for (let r = minR; r <= maxR; r++) {
const rowJson = []
const rowValues = []
html += '<tr>'
for (let c = minC; c <= maxC; c++) {
const cell = store.getCell(r, c)
// 深度拷贝单元格数据
rowJson.push(JSON.parse(JSON.stringify(cell)))
let val = cell.value == null ? '' : String(cell.value)
rowValues.push(val)
// 构建 HTML
let cellHtml = val.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
if (cell.type === 'image') cellHtml = `<img src="${val}" alt="image" style="max-width:100%;" />`
let styleStr = ''
if (cell.style) {
if (cell.style.bold) styleStr += 'font-weight:bold;'
if (cell.style.italic) styleStr += 'font-style:italic;'
if (cell.style.bg) styleStr += `background-color:${cell.style.bg};`
if (cell.style.align) styleStr += `text-align:${cell.style.align};`
}
html += `<td style="${styleStr}">${cellHtml}</td>`
}
jsonGrid.push(rowJson)
text += rowValues.join('\t') + (r < maxR ? '\n' : '')
html += '</tr>'
}
html += '</table>'
// 序列化 JSON 数据并嵌入 HTML 中,实现高保真粘贴
const serializedData = JSON.stringify(jsonGrid)
const wrappedHtml = `<div data-spreadsheet-json='${serializedData}'>${html}</div>`
const textBlob = new Blob([text], { type: 'text/plain' })
const htmlBlob = new Blob([wrappedHtml], { type: 'text/html' })
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': textBlob,
'text/html': htmlBlob
})
])
// 如果是剪切,只标记状态
if (isCut) {
store.setCutSelection()
} else {
store.clearCutSelection()
}
console.log('复制成功')
} catch (err) {
console.error('剪贴板操作失败', err)
}
}
/**
* 执行粘贴操作
* @param {Object} store Pinia Store 实例
*/
export const pasteFromClipboard = async (store) => {
try {
const items = await navigator.clipboard.read()
let pasted = false
for (const item of items) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html')
const html = await blob.text()
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const wrapper = doc.querySelector('[data-spreadsheet-json]')
if (wrapper) {
try {
// 优先尝试高保真 JSON 粘贴
pasteJsonGrid(store, JSON.parse(wrapper.dataset.spreadsheetJson))
pasted = true
} catch (e) {
console.warn('JSON 解析失败,降级处理', e)
}
}
if (!pasted) {
pasteHtml(store, html, doc)
pasted = true
}
break
}
}
if (!pasted) {
const text = await navigator.clipboard.readText()
pasteText(store, text)
}
// 粘贴完成后,检查是否有剪切状态。如果有,执行真正的剪切。
if (store.cutSelection) {
store.executeCut()
}
} catch (err) {
// 降级逻辑:直接读取文本
try {
const text = await navigator.clipboard.readText()
if (text) pasteText(store, text)
if (store.cutSelection) store.executeCut()
} catch(e) {
console.error('粘贴失败', e)
}
}
}
// --- 内部辅助函数 ---
const pasteJsonGrid = (store, grid) => {
const startR = store.selection.start.r
const startC = store.selection.start.c
grid.forEach((row, rIndex) => {
row.forEach((cellData, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
store.setCell(targetR, targetC, cellData)
if (cellData.type === 'image') {
ensureRowHeightForImage(store, targetR)
}
}
})
})
}
const pasteHtml = (store, html, doc = null) => {
if (!doc) {
const parser = new DOMParser()
doc = parser.parseFromString(html, 'text/html')
}
const trs = doc.querySelectorAll('tr')
// 处理非表格的 HTML 内容 (如单个图片或文本)
if (trs.length === 0) {
const img = doc.querySelector('img')
if(img && img.src) {
store.setCell(store.selection.start.r, store.selection.start.c, { type:'image', value: img.src })
ensureRowHeightForImage(store, store.selection.start.r)
} else {
pasteText(store, doc.body.textContent)
}
return
}
const startR = store.selection.start.r
const startC = store.selection.start.c
trs.forEach((tr, rIndex) => {
const tds = tr.querySelectorAll('td, th')
tds.forEach((td, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
const img = td.querySelector('img')
if (img && img.src) {
store.setCell(targetR, targetC, { value: img.src, type: 'image' })
ensureRowHeightForImage(store, targetR)
} else {
store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
}
}
})
})
}
const pasteText = (store, text) => {
if (!text) return
const rows = text.split(/\r\n|\n|\r/)
const startR = store.selection.start.r
const startC = store.selection.start.c
rows.forEach((rowStr, rIndex) => {
if (rIndex === rows.length - 1 && rowStr === '') return
const cols = rowStr.split('\t')
cols.forEach((val, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
const cleanVal = val.trim()
if (isImageUrl(cleanVal)) {
store.setCell(targetR, targetC, { value: cleanVal, type: 'image' })
ensureRowHeightForImage(store, targetR)
} else {
store.setCell(targetR, targetC, { value: val, type: 'text' })
}
}
})
})
}

15
src/utils/common.js Normal file
View File

@@ -0,0 +1,15 @@
// 检查字符串是否为图片 URL (支持 http/https 链接和 base64)
export const isImageUrl = (url) => {
if (!url || typeof url !== 'string') return false
const trimmed = url.trim()
if (trimmed.startsWith('data:image/')) return true
return /\.(jpeg|jpg|gif|png|bmp|webp|svg)(\?.*)?$/i.test(trimmed)
}
// 辅助:当插入图片时,确保行高至少为 100px 以便预览
export const ensureRowHeightForImage = (store, rowIndex) => {
const row = store.rows.find(r => r.index === rowIndex)
if (row && row.height < 100) {
row.height = 100
}
}

35
src/utils/file.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* 读取多个文件并返回 DataURL 数组
* @param {FileList | File[]} fileList
* @returns {Promise<string[]>}
*/
export const readFilesAsDataURL = async (fileList) => {
const files = Array.from(fileList)
if (files.length === 0) return []
const readers = files.map(file => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (evt) => resolve(evt.target.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
})
return Promise.all(readers)
}
/**
* 读取单个文件并返回 DataURL
* @param {File} file
* @returns {Promise<string | null>}
*/
export const readSingleFileAsDataURL = async (file) => {
if (!file) return null
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (evt) => resolve(evt.target.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}

226
src/utils/formula.js Normal file
View File

@@ -0,0 +1,226 @@
/**
* 将列索引 (0, 1, 2...) 转换为字母 (A, B, C...)
*/
export const indexToColName = (index) => {
let label = ''
let i = index
while (i >= 0) {
label = String.fromCharCode(65 + (i % 26)) + label
i = Math.floor(i / 26) - 1
}
return label
}
/**
* 将坐标 (A1) 解析为 {r, c}
*/
export const parseCellCoord = (coord) => {
const match = coord.match(/^([A-Z]+)([0-9]+)$/)
if (!match) return null
const colStr = match[1]
const rowStr = match[2]
let c = 0
for (let i = 0; i < colStr.length; i++) {
c = c * 26 + (colStr.charCodeAt(i) - 64)
}
return { r: parseInt(rowStr) - 1, c: c - 1 }
}
/**
* 函数注册表:包含实现逻辑、描述和语法提示
* 用于公式计算和 UI 提示
*/
export const FUNCTION_REGISTRY = {
// --- 数学与统计 ---
SUM: {
description: '计算单元格区域中所有数值的和',
syntax: 'SUM(number1, [number2], ...)',
impl: (...args) => {
return args.flat(Infinity).reduce((a, b) => {
const num = Number(b)
return isNaN(num) ? a : a + num
}, 0)
}
},
AVERAGE: {
description: '返回参数的平均值(算术平均值)',
syntax: 'AVERAGE(number1, [number2], ...)',
impl: (...args) => {
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
},
MAX: {
description: '返回一组值中的最大值',
syntax: 'MAX(number1, [number2], ...)',
impl: (...args) => {
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
return nums.length ? Math.max(...nums) : 0
}
},
MIN: {
description: '返回一组值中的最小值',
syntax: 'MIN(number1, [number2], ...)',
impl: (...args) => {
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
return nums.length ? Math.min(...nums) : 0
}
},
COUNT: {
description: '计算区域中包含数字的单元格个数',
syntax: 'COUNT(value1, [value2], ...)',
impl: (...args) => {
return args.flat(Infinity).filter(v => typeof v === 'number' || (typeof v === 'string' && !isNaN(Number(v)) && v.trim() !== '')).length
}
},
ROUND: {
description: '将数字四舍五入到指定的位数',
syntax: 'ROUND(number, num_digits)',
impl: (number, digits = 0) => {
const n = Number(number)
const d = Number(digits)
if (isNaN(n) || isNaN(d)) return '#VALUE!'
return Math.round(n * (10 ** d)) / (10 ** d)
}
},
ABS: {
description: '返回数字的绝对值',
syntax: 'ABS(number)',
impl: (number) => Math.abs(Number(number))
},
// --- 逻辑 ---
IF: {
description: '判断是否满足某个条件,如果满足返回一个值,如果不满足返回另一个值',
syntax: 'IF(logical_test, value_if_true, [value_if_false])',
impl: (condition, trueVal, falseVal) => {
return condition ? trueVal : (falseVal === undefined ? false : falseVal)
}
},
AND: {
description: '如果所有参数都为 TRUE则返回 TRUE',
syntax: 'AND(logical1, [logical2], ...)',
impl: (...args) => args.flat(Infinity).every(Boolean)
},
OR: {
description: '如果任一参数为 TRUE则返回 TRUE',
syntax: 'OR(logical1, [logical2], ...)',
impl: (...args) => args.flat(Infinity).some(Boolean)
},
// --- 文本 ---
CONCAT: {
description: '将两个或多个文本字符串联接为一个字符串',
syntax: 'CONCAT(text1, [text2], ...)',
impl: (...args) => args.flat(Infinity).join('')
},
LEN: {
description: '返回文本字符串中的字符个数',
syntax: 'LEN(text)',
impl: (text) => String(text || '').length
},
TRIM: {
description: '移除文本中多余的空格',
syntax: 'TRIM(text)',
impl: (text) => String(text || '').trim()
},
UPPER: {
description: '将文本转换为大写',
syntax: 'UPPER(text)',
impl: (text) => String(text || '').toUpperCase()
},
LOWER: {
description: '将文本转换为小写',
syntax: 'LOWER(text)',
impl: (text) => String(text || '').toLowerCase()
},
// --- 日期与时间 ---
NOW: {
description: '返回当前日期和时间',
syntax: 'NOW()',
impl: () => new Date().toLocaleString()
},
TODAY: {
description: '返回当前日期',
syntax: 'TODAY()',
impl: () => new Date().toLocaleDateString()
}
}
/**
* 简易公式求值器
* @param {String} formula 公式字符串 (e.g. "=SUM(A1:B2) + 10")
* @param {Function} getValueCb 回调函数,用于获取指定单元格的值 (r, c) => val
*/
export const evaluateFormula = (formula, getValueCb) => {
if (!formula || typeof formula !== 'string' || !formula.startsWith('=')) return formula
const expression = formula.substring(1).trim()
if (!expression) return ''
// 1. 预处理范围引用:将 A1:B2 转换为数值数组 [1, 2, 3, 4]
// 注意:为了支持文本函数,如果单元格内容是文本,也需要正确提取
const rangeRegex = /([A-Z]+[0-9]+):([A-Z]+[0-9]+)/g
const parsedExpressionWithRanges = expression.replace(rangeRegex, (match, start, end) => {
const s = parseCellCoord(start)
const e = parseCellCoord(end)
if (!s || !e) return match
const values = []
const minR = Math.min(s.r, e.r), maxR = Math.max(s.r, e.r)
const minC = Math.min(s.c, e.c), maxC = Math.max(s.c, e.c)
for(let r=minR; r<=maxR; r++) {
for(let c=minC; c<=maxC; c++) {
let val = getValueCb(r, c)
// 尝试转为数字,如果不仅是数字字符则保留原值(用于文本处理)
const numVal = Number(val)
if (!isNaN(numVal) && val !== '') {
values.push(numVal)
} else {
// 简单的转义处理,防止字符串破坏数组结构
values.push(`"${String(val).replace(/"/g, '\\"')}"`)
}
}
}
return `[${values.join(',')}]`
})
// 2. 预处理单个单元格引用:将 A1 转换为具体值
// 复杂的正则用于避免匹配到已经是字符串中的内容,这里做简化处理
// 我们假设单元格引用不会出现在引号内部(这是一个简易引擎的限制)
const cellRegex = /(?<!["'[a-zA-Z0-9_])([A-Z]+[0-9]+)(?!["'[a-zA-Z0-9_])/g
const finalExpression = parsedExpressionWithRanges.replace(cellRegex, (match) => {
const coords = parseCellCoord(match)
if (!coords) return 0
const val = getValueCb(coords.r, coords.c)
const numVal = Number(val)
// 如果是纯数字,返回数字;否则返回带引号的字符串
return (!isNaN(numVal) && val !== '') ? numVal : `"${String(val).replace(/"/g, '\\"')}"`
})
// 3. 构建执行上下文
// 将 FUNCTION_REGISTRY 中的 impl 提取出来作为参数传递给 Function 构造器
const funcNames = Object.keys(FUNCTION_REGISTRY)
const funcImpls = funcNames.map(name => FUNCTION_REGISTRY[name].impl)
try {
// 注入所有支持的函数到执行作用域
// e.g. new Function('SUM', 'AVERAGE', ..., 'return SUM(1, 2)')
const func = new Function(...funcNames, `return ${finalExpression}`)
const result = func(...funcImpls)
// 如果结果是 NaN 或 Infinity做一些友好的处理
if (typeof result === 'number' && isNaN(result)) return '#VALUE!'
if (typeof result === 'number' && !isFinite(result)) return '#DIV/0!'
return result
} catch (e) {
console.warn(`Formula Eval Error: ${formula}`, e)
return '#ERROR!'
}
}

107
src/utils/keyboard.js Normal file
View File

@@ -0,0 +1,107 @@
import { copyToClipboard, pasteFromClipboard } from './clipboard'
/**
* 统一处理网格的键盘事件
* @param {KeyboardEvent} e 事件对象
* @param {Object} store Pinia Store 实例
*/
export const handleGridKeyDown = async (e, store) => {
// 如果正在编辑单元格,不拦截(除了特定的快捷键)
if (store.isEditing) return
const { r, c } = store.selection.start
const isCtrl = e.ctrlKey || e.metaKey
const key = e.key.toLowerCase()
// 1. 系统级操作 (Ctrl 组合键)
if (isCtrl) {
if (key === 'c') {
e.preventDefault()
await copyToClipboard(store)
return
}
if (key === 'x') {
e.preventDefault()
await copyToClipboard(store, true) // 剪切模式
return
}
if (key === 'v') {
e.preventDefault()
await pasteFromClipboard(store)
return
}
if (key === 'z') {
e.preventDefault()
e.shiftKey ? store.redo() : store.undo()
return
}
if (key === 'y') {
e.preventDefault()
store.redo()
return
}
if (key === 'a') {
e.preventDefault()
store.selectAll()
return
}
}
// 2. 导航 (Tab)
if (e.key === 'Tab') {
e.preventDefault()
if (e.shiftKey) {
if (c > 0) store.select(r, c - 1)
} else {
if (c < store.cols.length - 1) store.select(r, c + 1)
}
return
}
// 3. 状态控制 (Esc)
if (e.key === 'Escape') {
e.preventDefault()
store.clearCutSelection()
return
}
// 4. 方向键 (Arrow Keys)
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault()
if (e.key === 'ArrowUp' && r > 0) store.select(r - 1, c)
else if (e.key === 'ArrowDown') store.select(r + 1, c)
else if (e.key === 'ArrowLeft' && c > 0) store.select(r, c - 1)
else if (e.key === 'ArrowRight') store.select(r, c + 1)
return
}
// 5. 回车行为 (Enter)
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
if (r > 0) store.select(r - 1, c)
} else {
store.select(r + 1, c)
}
return
}
// 6. 快捷编辑/删除 (F2, Backspace, Delete)
if (e.key === 'F2') {
e.preventDefault()
store.enableEditing()
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
store.clearSelection()
return
}
// 7. 普通输入触发编辑
// 排除 Ctrl/Alt 组合键,只处理单字符或 Shift+字符
if (e.key.length === 1 && !isCtrl && !e.altKey) {
e.preventDefault()
store.enableEditing()
store.editingValue = e.key
}
}

30
src/utils/layout.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* 根据相对坐标 (x, y) 计算单元格行列索引
* @param {Number} x 相对于网格内容区域左上角的 X 坐标
* @param {Number} y 相对于网格内容区域左上角的 Y 坐标
* @param {Array} rows 行配置数组
* @param {Array} cols 列配置数组
* @returns {Object} { r, c } 如果坐标无效(如在负区域)返回 { r: -1, c: -1 }
*/
export const resolveCellPosition = (x, y, rows, cols) => {
if (x < 0 || y < 0) return { r: -1, c: -1 }
let c = 0, currentX = 0
// 遍历列宽
while(c < cols.length && currentX + cols[c].width < x) {
currentX += cols[c].width
c++
}
let r = 0, currentY = 0
// 遍历行高
while(r < rows.length && currentY + rows[r].height < y) {
currentY += rows[r].height
r++
}
return {
r: Math.min(r, rows.length - 1),
c: Math.min(c, cols.length - 1)
}
}

View File

@@ -0,0 +1,69 @@
import { ensureRowHeightForImage } from './common'
/**
* 统一处理工具栏的动作分发
* @param {Object} store Pinia Store 实例
* @param {String} type 动作类型
* @param {Any} payload 动作参数
*/
export const handleToolbarAction = (store, type, payload) => {
switch (type) {
case 'undo':
store.undo()
break
case 'redo':
store.redo()
break
case 'toggleStyle':
store.toggleStyle(payload)
break
case 'setAlign':
store.setStyle('align', payload)
break
case 'setStyle':
if (payload && typeof payload === 'object') {
Object.entries(payload).forEach(([key, value]) => {
store.setStyle(key, value)
// 如果是颜色相关的设置,添加到历史记录
if (key === 'color' || key === 'bg') {
store.addToColorHistory(value)
}
})
}
break
case 'insertImages':
handleInsertImages(store, payload)
break
case 'addRow':
store.insertRow(store.selection.start.r + 1)
break
case 'addCol':
store.insertCol(store.selection.start.c + 1)
break
default:
console.warn('未知的工具栏动作:', type)
}
}
const handleInsertImages = (store, images) => {
if (!images || !images.length) return
const startR = store.selection.start.r
const startC = store.selection.start.c
ensureRowHeightForImage(store, startR)
images.forEach((imgUrl, index) => {
const targetC = startC + index
if (targetC < store.cols.length) {
store.setCell(startR, targetC, { value: imgUrl, type: 'image' })
}
})
}

54
src/views/excel/index.vue Normal file
View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import Toolbar from '@/components/spreadsheet/Toolbar.vue'
import FormulaBar from '@/components/spreadsheet/FormulaBar.vue'
import GridEditor from '@/components/spreadsheet/GridEditor.vue'
import StatusBar from '@/components/spreadsheet/StatusBar.vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
// 引入封装好的工具栏逻辑
import { handleToolbarAction } from '@/utils/toolbarActions'
const store = useSpreadsheetStore()
onMounted(() => {
// 从 IndexedDB 加载上次的数据
store.loadData()
})
// 统一处理工具栏事件
const onToolbarAction = (type: string, payload?: any) => {
handleToolbarAction(store, type, payload)
}
</script>
<template>
<div class="flex flex-col h-full w-full bg-white">
<!-- 工具栏 -->
<Toolbar
:current-style="store.currentStyle"
@action="onToolbarAction"
/>
<!-- 公式栏 -->
<FormulaBar
:value="store.activeCellFormula"
@update="val => store.updateActiveCell(val)"
/>
<!-- 网格编辑器 (自适应剩余空间) -->
<div class="flex-1 overflow-hidden relative">
<!-- 加载遮罩 -->
<div v-if="store.loading" class="absolute inset-0 flex items-center justify-center bg-white z-50">
<span class="text-gray-500 text-sm">正在加载表格数据...</span>
</div>
<GridEditor />
</div>
<!-- 底部状态栏 -->
<StatusBar />
</div>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff