+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleColorChange('color', c)"
+ />
+ handleColorChange('bg', c)"
+ />
+
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/router/index.js b/src/router/index.js
index 796771d..010fb28 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -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({
diff --git a/src/stores/spreadsheet.js b/src/stores/spreadsheet.js
index 246bf59..25d03af 100644
--- a/src/stores/spreadsheet.js
+++ b/src/stores/spreadsheet.js
@@ -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
{
}
})
- // --- 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 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 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 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 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
}
})
\ No newline at end of file
diff --git a/src/utils/clipboard.js b/src/utils/clipboard.js
new file mode 100644
index 0000000..68286c6
--- /dev/null
+++ b/src/utils/clipboard.js
@@ -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 = ''
+
+ for (let r = minR; r <= maxR; r++) {
+ const rowJson = []
+ const rowValues = []
+ html += ''
+ 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, "&").replace(//g, ">")
+ if (cell.type === 'image') cellHtml = ` `
+
+ 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 += `${cellHtml} `
+ }
+ jsonGrid.push(rowJson)
+ text += rowValues.join('\t') + (r < maxR ? '\n' : '')
+ html += ' '
+ }
+ html += '
'
+
+ // 序列化 JSON 数据并嵌入 HTML 中,实现高保真粘贴
+ const serializedData = JSON.stringify(jsonGrid)
+ const wrappedHtml = `${html}
`
+
+ 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' })
+ }
+ }
+ })
+ })
+}
\ No newline at end of file
diff --git a/src/utils/common.js b/src/utils/common.js
new file mode 100644
index 0000000..61bce7e
--- /dev/null
+++ b/src/utils/common.js
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/src/utils/file.js b/src/utils/file.js
new file mode 100644
index 0000000..eaaf8af
--- /dev/null
+++ b/src/utils/file.js
@@ -0,0 +1,35 @@
+/**
+ * 读取多个文件并返回 DataURL 数组
+ * @param {FileList | File[]} fileList
+ * @returns {Promise}
+ */
+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}
+ */
+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)
+ })
+}
\ No newline at end of file
diff --git a/src/utils/formula.js b/src/utils/formula.js
new file mode 100644
index 0000000..d3491dc
--- /dev/null
+++ b/src/utils/formula.js
@@ -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 = /(? {
+ 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!'
+ }
+}
\ No newline at end of file
diff --git a/src/utils/keyboard.js b/src/utils/keyboard.js
new file mode 100644
index 0000000..af2e53a
--- /dev/null
+++ b/src/utils/keyboard.js
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/src/utils/layout.js b/src/utils/layout.js
new file mode 100644
index 0000000..3110e85
--- /dev/null
+++ b/src/utils/layout.js
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/src/utils/toolbarActions.js b/src/utils/toolbarActions.js
new file mode 100644
index 0000000..4f5e280
--- /dev/null
+++ b/src/utils/toolbarActions.js
@@ -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' })
+ }
+ })
+}
\ No newline at end of file
diff --git a/src/views/excel/index.vue b/src/views/excel/index.vue
new file mode 100644
index 0000000..66411ff
--- /dev/null
+++ b/src/views/excel/index.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
store.updateActiveCell(val)"
+ />
+
+
+
+
+
+ 正在加载表格数据...
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/index/index.vue b/src/views/index/index.vue
index af55a5c..4c0f934 100644
--- a/src/views/index/index.vue
+++ b/src/views/index/index.vue
@@ -1,73 +1,1002 @@
-
-
-
-
-
-
-
-
store.updateActiveCell(val)"
- />
-
-
-
-
- 正在加载表格数据...
-
-
+
+
+
-
-
+
+
+
+
+
+
+ 数据艺术
+ 工坊
+
+
+ 在这里,数据不仅仅是数字,而是艺术的原材料。我们精心打造的工具将帮助您将枯燥的数据转化为令人惊叹的艺术作品。
+
+
+
+ 1,247
+ 今日处理文件
+
+
+ 89%
+ 用户满意度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Excel艺术工具
+
+ 将电子表格转化为数据艺术作品,提供高级分析、可视化与格式美化功能,让枯燥的数据变得生动有趣。
+
+
+
+
+
+
+
+
+
+ 智能表格美化
+
+
+
+
+
+ 高级数据可视化
+
+
+
+
+
+ 艺术模板库
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
更多工具开发中
+
+ 我们正在积极开发更多数据艺术工具,包括图表生成器、数据故事讲述工具等,敬请期待。
+
+
+
+
+
+
+
+
+
+
+
+
+ 快速开始指南
+
+
+
1
+
+
上传您的数据
+
支持Excel、CSV等多种格式,无需复杂配置
+
+
+
+
2
+
+
选择艺术模板
+
从我们的艺术模板库中选择适合的样式
+
+
+
+
3
+
+
自定义与导出
+
调整细节并导出为多种格式,分享您的作品
+
+
+
+
+
+
+
+
+
+
+
+
+
+
正在跳转到 {{ currentAction }} 功能...
+
+
+
+
+
\ No newline at end of file