初始化
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<title>奶酪文档</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.13)
|
||||
'@fortawesome/fontawesome-free':
|
||||
specifier: ^6.7.2
|
||||
version: 6.7.2
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
|
||||
@@ -23,9 +26,6 @@ importers:
|
||||
ant-design-vue:
|
||||
specifier: ^4.1.1
|
||||
version: 4.2.6(vue@3.5.13)
|
||||
aos:
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
axios:
|
||||
specifier: ^1.6.2
|
||||
version: 1.9.0
|
||||
@@ -263,6 +263,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2':
|
||||
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
@@ -295,42 +299,36 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -388,67 +386,56 @@ packages:
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
@@ -506,28 +493,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.4':
|
||||
resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.4':
|
||||
resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.4':
|
||||
resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.4':
|
||||
resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==}
|
||||
@@ -632,9 +615,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3.2.0'
|
||||
|
||||
aos@2.3.4:
|
||||
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
|
||||
|
||||
array-tree-filter@2.1.0:
|
||||
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
|
||||
|
||||
@@ -659,9 +639,6 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
classlist-polyfill@1.2.0:
|
||||
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -851,28 +828,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.29.2:
|
||||
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.29.2:
|
||||
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.29.2:
|
||||
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.29.2:
|
||||
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
|
||||
@@ -893,12 +866,6 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -1229,6 +1196,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.3':
|
||||
optional: true
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
@@ -1547,12 +1516,6 @@ snapshots:
|
||||
vue-types: 3.0.2(vue@3.5.13)
|
||||
warning: 4.0.3
|
||||
|
||||
aos@2.3.4:
|
||||
dependencies:
|
||||
classlist-polyfill: 1.2.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.throttle: 4.1.1
|
||||
|
||||
array-tree-filter@2.1.0: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
@@ -1581,8 +1544,6 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
classlist-polyfill@1.2.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -1787,10 +1748,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
||||
29
src/components/spreadsheet/Cell.vue
Normal file
29
src/components/spreadsheet/Cell.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(['data', 'width', 'height', 'r', 'c'])
|
||||
|
||||
const styleObject = computed(() => {
|
||||
const s = props.data.style || {}
|
||||
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'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-b border-gray-200 text-xs 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>
|
||||
</div>
|
||||
</template>
|
||||
37
src/components/spreadsheet/ContextMenu.vue
Normal file
37
src/components/spreadsheet/ContextMenu.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { Image as ImageIcon } from 'lucide-vue-next'
|
||||
const props = defineProps(['x', 'y'])
|
||||
const emit = defineEmits(['action', 'close'])
|
||||
|
||||
const emitAction = (name) => {
|
||||
emit('action', name)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bg-white rounded shadow-xl border border-gray-200 py-1 w-48 text-xs text-gray-700 z-[9999]"
|
||||
:style="{ top: y + 'px', left: x + 'px' }"
|
||||
@click.stop>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between" @click="emitAction('copy')">
|
||||
<span>复制</span><span class="text-gray-400">Ctrl+C</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between" @click="emitAction('paste')">
|
||||
<span>粘贴</span><span class="text-gray-400">Ctrl+V</span>
|
||||
</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
|
||||
<!-- 新增:右键插入图片 -->
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertImage')">
|
||||
<ImageIcon class="w-3 h-3 text-gray-500" />
|
||||
<span>插入图片...</span>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer" @click="emitAction('insertRowAbove')">在上方插入 1 行</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer" @click="emitAction('insertRowBelow')">在下方插入 1 行</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-500" @click="emitAction('deleteRow')">删除所在行</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-500" @click="emitAction('clear')">清除内容</div>
|
||||
</div>
|
||||
</template>
|
||||
17
src/components/spreadsheet/FormulaBar.vue
Normal file
17
src/components/spreadsheet/FormulaBar.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps(['value'])
|
||||
defineEmits(['update'])
|
||||
</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="w-px h-4 bg-gray-300 mx-1"></div>
|
||||
<input
|
||||
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)"
|
||||
placeholder="输入值或公式"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
446
src/components/spreadsheet/GridEditor.vue
Normal file
446
src/components/spreadsheet/GridEditor.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick, watchEffect } from 'vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
import Cell from './Cell.vue'
|
||||
import SelectionOverlay from './SelectionOverlay.vue'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
|
||||
const store = useSpreadsheetStore()
|
||||
const scroller = ref(null)
|
||||
const contextMenu = reactive({ visible: false, x: 0, y: 0 })
|
||||
const hiddenFileInput = ref(null) // 隐藏的文件上传 input
|
||||
|
||||
// 交互状态
|
||||
const isDragging = ref(false)
|
||||
const isFilling = ref(false)
|
||||
|
||||
const HEADER_WIDTH = 40
|
||||
const HEADER_HEIGHT = 32
|
||||
|
||||
// --- 核心:高级复制/粘贴逻辑 ---
|
||||
|
||||
const performCopy = async () => {
|
||||
try {
|
||||
const { minR, maxR, minC, maxC } = store
|
||||
|
||||
// 1. 构建用于内部粘贴的完整数据结构 (2D Array of JSON)
|
||||
const jsonGrid = []
|
||||
|
||||
// 2. 构建 HTML 和 Text
|
||||
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)
|
||||
|
||||
// 保存完整状态到 JSON Grid
|
||||
rowJson.push(JSON.parse(JSON.stringify(cell))) // Deep copy
|
||||
|
||||
// 文本格式
|
||||
let val = cell.value == null ? '' : String(cell.value)
|
||||
rowValues.push(val)
|
||||
|
||||
// HTML 格式 (外部兼容)
|
||||
let cellHtml = val.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
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 中 ***
|
||||
// 我们使用 data-spreadsheet-json 属性包裹序列化后的数据
|
||||
// 这样在粘贴 HTML 时,我们可以提取这个属性来获得 100% 的还原度
|
||||
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
|
||||
})
|
||||
])
|
||||
console.log('Copied with Rich Format')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', 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()
|
||||
|
||||
// 1. 尝试提取内部 JSON 数据
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const wrapper = doc.querySelector('[data-spreadsheet-json]')
|
||||
|
||||
if (wrapper) {
|
||||
// 命中!这是我们自己的数据,完美还原
|
||||
try {
|
||||
const jsonGrid = JSON.parse(wrapper.dataset.spreadsheetJson)
|
||||
pasteJsonGrid(jsonGrid)
|
||||
pasted = true
|
||||
console.log('Pasted High-Fidelity JSON')
|
||||
} catch (e) {
|
||||
console.error('Failed to parse internal JSON', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pasted) {
|
||||
// 2. 没有 JSON,走普通 HTML 解析 (保留图片链接等)
|
||||
pasteHtml(html)
|
||||
pasted = true
|
||||
console.log('Pasted HTML')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pasted) {
|
||||
// 3. 降级到纯文本
|
||||
const text = await navigator.clipboard.readText()
|
||||
pasteText(text)
|
||||
console.log('Pasted Text')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Paste failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 粘贴 JSON Grid (高保真)
|
||||
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) {
|
||||
// 直接覆盖整个 Cell 对象 (包括 style, type, value)
|
||||
store.setCell(targetR, targetC, cellData)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 粘贴 HTML (普通)
|
||||
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 })
|
||||
else pasteText(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' })
|
||||
} else {
|
||||
// 这里我们只恢复文本,不恢复样式(因为外部 HTML 样式太复杂)
|
||||
// 如果需要,可以解析 td.style
|
||||
store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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' })
|
||||
} else {
|
||||
store.setCell(targetR, targetC, { value: val, type: 'text' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- 右键上传图片逻辑 ---
|
||||
|
||||
const triggerHiddenFileInput = () => {
|
||||
hiddenFileInput.value.click()
|
||||
}
|
||||
|
||||
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
|
||||
// 插入到当前选中单元格
|
||||
store.setCell(store.selection.start.r, store.selection.start.c, {
|
||||
type: 'image',
|
||||
value: base64
|
||||
})
|
||||
// 自动调整当前行高以便预览
|
||||
const row = store.rows.find(r => r.index === store.selection.start.r)
|
||||
if (row) row.height = 100
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = '' // reset
|
||||
}
|
||||
|
||||
// --- 基础交互 ---
|
||||
|
||||
const getCellByPoint = (x, y) => {
|
||||
if (!scroller.value) return { r: -1, c: -1 }
|
||||
const contentEl = scroller.value.firstElementChild
|
||||
if (!contentEl) return { r: -1, c: -1 }
|
||||
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) }
|
||||
}
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return
|
||||
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
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (isDragging.value || isFilling.value) {
|
||||
const { r, c } = getCellByPoint(e.clientX, e.clientY)
|
||||
if (r !== -1 && c !== -1) store.extendSelection(r, c)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
scroller.value?.parentElement?.focus()
|
||||
}
|
||||
if (isFilling.value) {
|
||||
isFilling.value = false
|
||||
const { r, c } = store.selection.end
|
||||
store.autoFill(r, c)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
contextMenu.x = e.clientX
|
||||
contextMenu.y = e.clientY
|
||||
contextMenu.visible = true
|
||||
}
|
||||
|
||||
const handleContextAction = (action) => {
|
||||
const r = store.selection.start.r
|
||||
const c = store.selection.start.c
|
||||
switch(action) {
|
||||
case 'copy': performCopy(); break;
|
||||
case 'paste': performPaste(); break;
|
||||
case 'insertImage': triggerHiddenFileInput(); break;
|
||||
case 'insertRowAbove': store.insertRow(r, 1); break;
|
||||
case 'insertRowBelow': store.insertRow(r + 1, 1); break;
|
||||
case 'deleteRow': store.deleteRow(r); break;
|
||||
case 'clear': store.forEachSelectedCell((row,col) => store.setCell(row,col,{value:''})); break;
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (store.isEditing) return
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault(); performCopy(); return
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault(); performPaste(); return
|
||||
}
|
||||
|
||||
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) e.preventDefault()
|
||||
|
||||
const current = store.selection.start
|
||||
if (e.key === 'ArrowUp' && current.r > 0) store.select(current.r - 1, current.c)
|
||||
else if (e.key === 'ArrowDown') store.select(current.r + 1, current.c)
|
||||
else if (e.key === 'ArrowLeft' && current.c > 0) store.select(current.r, current.c - 1)
|
||||
else if (e.key === 'ArrowRight') store.select(current.r, current.c + 1)
|
||||
else if (e.key === 'Enter') store.select(current.r + 1, current.c)
|
||||
else if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.forEachSelectedCell((r, c) => store.setCell(r, c, { value: '', type: 'text' }))
|
||||
}
|
||||
else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
store.enableEditing()
|
||||
store.editingValue = e.key
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (store.isEditing) {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('textarea.grid-editor-textarea')
|
||||
if(el) el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full outline-none" tabindex="0" @keydown="handleKeyDown">
|
||||
<!-- 隐藏的文件输入,用于右键上传 -->
|
||||
<input type="file" ref="hiddenFileInput" class="hidden" accept="image/*" @change="handleContextMenuImageUpload">
|
||||
|
||||
<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"
|
||||
class="flex items-center justify-center border-r border-gray-300 text-xs text-gray-500 font-medium bg-[#f4f5f7] hover:bg-gray-200 relative"
|
||||
:class="{'bg-green-100 text-green-600 font-bold': store.isColSelected(i)}"
|
||||
:style="{ width: col.width + 'px', flexShrink: 0 }">
|
||||
{{ col.label }}
|
||||
</div>
|
||||
</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"
|
||||
:class="{'bg-green-100 text-green-600 font-bold': store.isRowSelected(i)}"
|
||||
:style="{ height: row.height + 'px' }">
|
||||
{{ row.index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单元格区域 -->
|
||||
<div class="relative"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp">
|
||||
|
||||
<div v-for="(row, r) in store.rows" :key="r" class="flex w-max">
|
||||
<Cell
|
||||
v-for="(col, c) in store.cols"
|
||||
:key="c"
|
||||
:r="r" :c="c"
|
||||
:data="store.getCell(r, c)"
|
||||
:width="col.width"
|
||||
:height="row.height"
|
||||
@dblclick="store.enableEditing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectionOverlay
|
||||
:selection="store.selection"
|
||||
:is-editing="store.isEditing"
|
||||
:layout-info="store.layoutInfo"
|
||||
@fill-drag-start="isFilling = true"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-if="store.isEditing"
|
||||
v-model="store.editingValue"
|
||||
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"
|
||||
></textarea>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
v-if="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@action="handleContextAction"
|
||||
@close="contextMenu.visible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #f5f5f5; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #ccc; border-radius: 5px; border: 2px solid #f5f5f5; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #999; }
|
||||
</style>
|
||||
26
src/components/spreadsheet/SelectionOverlay.vue
Normal file
26
src/components/spreadsheet/SelectionOverlay.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
|
||||
const props = defineProps(['selection', 'layoutInfo', 'isEditing'])
|
||||
defineEmits(['fill-drag-start'])
|
||||
|
||||
const fillPreview = ref(false)
|
||||
const fillPreviewRect = reactive({ w:0, h:0 })
|
||||
|
||||
const style = computed(() => ({
|
||||
top: props.layoutInfo.top + 'px',
|
||||
left: props.layoutInfo.left + 'px',
|
||||
width: props.layoutInfo.width + 'px',
|
||||
height: props.layoutInfo.height + 'px',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none z-20 absolute border-2 border-green-500 bg-green-500/10 transition-all duration-75"
|
||||
:style="style">
|
||||
<div v-if="!isEditing"
|
||||
class="pointer-events-auto cursor-crosshair absolute w-2 h-2 bg-green-500 border border-white -bottom-1.5 -right-1.5 z-50"
|
||||
@mousedown.stop="$emit('fill-drag-start', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/spreadsheet/StatusBar.vue
Normal file
16
src/components/spreadsheet/StatusBar.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { Menu, Plus } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-7 bg-[#fbfbfb] border-t border-gray-300 flex items-center justify-between px-3 text-[11px] text-gray-500 select-none shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="px-2 py-0.5 bg-white border border-gray-200 rounded shadow-sm flex items-center gap-1 text-green-600 font-bold cursor-pointer">
|
||||
<Menu class="w-3 h-3" /> Sheet1
|
||||
</div>
|
||||
<div class="hover:bg-gray-200 px-2 py-0.5 rounded cursor-pointer transition">Sheet2</div>
|
||||
<Plus class="w-3 h-3 cursor-pointer hover:bg-gray-200 rounded" />
|
||||
</div>
|
||||
<div>就绪</div>
|
||||
</div>
|
||||
</template>
|
||||
94
src/components/spreadsheet/Toolbar.vue
Normal file
94
src/components/spreadsheet/Toolbar.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
Undo2, Redo2, Bold, Italic, Underline,
|
||||
AlignLeft, AlignCenter, AlignRight, Image as ImageIcon, Plus
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps(['currentStyle'])
|
||||
const emit = defineEmits(['action'])
|
||||
|
||||
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)
|
||||
} 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 activeClass = "bg-gray-300 text-black"
|
||||
</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>
|
||||
|
||||
<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="w-px h-4 bg-gray-300 mx-1 self-center"></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>
|
||||
|
||||
<div class="flex gap-1 pr-2 border-r border-gray-300">
|
||||
<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="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')">
|
||||
<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')">
|
||||
<Plus class="w-3 h-3" /> 列
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,31 +2,38 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo -->
|
||||
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold">My App</span>
|
||||
</div>
|
||||
<div class="flex flex-col h-screen overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-3 px-6 flex items-center justify-between shrink-0 z-50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo -->
|
||||
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold text-lg">My App</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="hidden md:flex space-x-4">
|
||||
<a href="#" class="hover:text-gray-300">首页</a>
|
||||
<a href="#" class="hover:text-gray-300">About</a>
|
||||
<a href="#" class="hover:text-gray-300">Services</a>
|
||||
<a href="#" class="hover:text-gray-300">Contact</a>
|
||||
</nav>
|
||||
<!-- Menu -->
|
||||
<nav class="hidden md:flex space-x-6 text-sm font-medium">
|
||||
<a href="#" class="hover:text-blue-200 transition">首页</a>
|
||||
<a href="#" class="hover:text-blue-200 transition">关于</a>
|
||||
<a href="#" class="hover:text-blue-200 transition">服务</a>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||
Personal Center
|
||||
</button>
|
||||
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
|
||||
</div>
|
||||
</header>
|
||||
<!-- User Profile -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-xs bg-black/20 hover:bg-black/30 text-white py-1.5 px-3 rounded transition">
|
||||
个人中心
|
||||
</button>
|
||||
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer border-2 border-white/20">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||
<!-- 这里是路由出口,index.vue 会渲染在这里 -->
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
<style scoped>
|
||||
</style>
|
||||
242
src/stores/spreadsheet.js
Normal file
242
src/stores/spreadsheet.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { db } from '@/utils/db'
|
||||
|
||||
export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
// --- State ---
|
||||
// 初始化 100 行 26 列 (默认空)
|
||||
const rows = ref(Array.from({ length: 100 }, (_, i) => ({ index: i, height: 25 })))
|
||||
const cols = ref(Array.from({ length: 26 }, (_, i) => ({ label: String.fromCharCode(65 + i), width: 100 })))
|
||||
|
||||
// 核心数据:Map<"r_c", CellJSON>
|
||||
const cells = reactive({})
|
||||
|
||||
const selection = reactive({ start: { r: 1, c: 1 }, end: { r: 1, c: 1 } })
|
||||
const isEditing = ref(false)
|
||||
const editingValue = ref('')
|
||||
const loading = ref(true) // 加载状态
|
||||
|
||||
// --- Helpers ---
|
||||
const getKey = (r, c) => `${r}_${c}`
|
||||
|
||||
// --- Persistence ---
|
||||
const SHEET_KEY = 'default_sheet_v1'
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await db.get(SHEET_KEY)
|
||||
if (data) {
|
||||
// 恢复数据
|
||||
Object.assign(cells, data.cells)
|
||||
if (data.rows) rows.value = data.rows
|
||||
if (data.cols) cols.value = data.cols
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load DB', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 自动保存 (防抖建议在生产环境加,这里直接监听)
|
||||
watch([cells, rows, cols], () => {
|
||||
if (!loading.value) {
|
||||
db.set(SHEET_KEY, {
|
||||
cells,
|
||||
rows: rows.value,
|
||||
cols: cols.value
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// --- Getters ---
|
||||
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) => {
|
||||
return cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
|
||||
}
|
||||
|
||||
const currentStyle = computed(() => {
|
||||
const cell = getCell(selection.start.r, selection.start.c)
|
||||
return cell.style || {}
|
||||
})
|
||||
|
||||
const activeCellFormula = computed(() => {
|
||||
if (isEditing.value) return editingValue.value
|
||||
const cell = getCell(selection.start.r, selection.start.c)
|
||||
return String(cell.value || '')
|
||||
})
|
||||
|
||||
// 计算选区覆盖层位置
|
||||
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 }
|
||||
})
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
const startR = selection.start.r
|
||||
const startC = selection.start.c
|
||||
const lInfo = layoutInfo.value // 复用计算结果的起始位置,但不包含宽高累加
|
||||
|
||||
// 重新计算起始点的绝对位置(为了精确性建议封装通用方法,这里简化复用)
|
||||
let top = 0; for(let i=0; i<startR; i++) top += rows.value[i].height
|
||||
let left = 0; for(let i=0; i<startC; i++) left += cols.value[i].width
|
||||
|
||||
const w = cols.value[startC].width
|
||||
const h = rows.value[startR].height
|
||||
const style = getCell(startR, startC).style || {}
|
||||
|
||||
return {
|
||||
top: top + 'px', left: left + 'px',
|
||||
minWidth: w + 'px', minHeight: h + 'px',
|
||||
textAlign: style.align || 'left',
|
||||
fontWeight: style.bold ? 'bold' : 'normal',
|
||||
fontStyle: style.italic ? 'italic' : 'normal',
|
||||
textDecoration: style.underline ? 'underline' : 'none',
|
||||
}
|
||||
})
|
||||
|
||||
// --- Actions ---
|
||||
function setCell(r, c, partialData) {
|
||||
const key = getKey(r, c)
|
||||
if (!cells[key]) {
|
||||
cells[key] = { type: 'text', value: '', style: {} }
|
||||
}
|
||||
|
||||
// 深度合并逻辑 (这里做简单合并,生产环境建议用 lodash.merge)
|
||||
if (partialData.style) {
|
||||
cells[key].style = { ...cells[key].style, ...partialData.style }
|
||||
}
|
||||
if (partialData.value !== undefined) cells[key].value = partialData.value
|
||||
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 }
|
||||
}
|
||||
|
||||
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 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 } })
|
||||
})
|
||||
}
|
||||
|
||||
function insertRow(index, count = 1) {
|
||||
// 实际项目中需要处理数据的下移(修改 cells 的 key),这里仅演示 UI 层的插入
|
||||
for(let k=0; k<count; k++) {
|
||||
rows.value.splice(index, 0, { index: -1, height: 25 })
|
||||
}
|
||||
rows.value.forEach((r, i) => r.index = i)
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
}
|
||||
|
||||
function insertCol(index, count = 1) {
|
||||
for(let k=0; k<count; k++) {
|
||||
cols.value.splice(index, 0, { label: 'New', width: 100 })
|
||||
}
|
||||
// Re-label
|
||||
cols.value.forEach((col, i) => {
|
||||
col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26))
|
||||
})
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
}
|
||||
|
||||
function deleteRow(index) {
|
||||
rows.value.splice(index, 1)
|
||||
rows.value.forEach((r, i) => r.index = i)
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
}
|
||||
|
||||
function autoFill(targetEndR, targetEndC) {
|
||||
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
|
||||
// 深度拷贝数据
|
||||
const newData = JSON.parse(JSON.stringify(template))
|
||||
setCell(r, c, newData)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
|
||||
return {
|
||||
rows, cols, cells, selection, isEditing, editingValue, loading,
|
||||
minR, maxR, minC, maxC, totalWidth, totalHeight,
|
||||
getCell, currentStyle, activeCellFormula, layoutInfo, editorStyle,
|
||||
isColSelected, isRowSelected,
|
||||
loadData,
|
||||
select, extendSelection, selectAll,
|
||||
enableEditing, finishEditing, updateActiveCell, setCell,
|
||||
toggleStyle, setStyle,
|
||||
insertRow, insertCol, deleteRow, autoFill, forEachSelectedCell
|
||||
}
|
||||
})
|
||||
46
src/utils/db.js
Normal file
46
src/utils/db.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// 简单的 IndexedDB 封装
|
||||
const DB_NAME = 'SpreadsheetDB'
|
||||
const STORE_NAME = 'sheets'
|
||||
const DB_VERSION = 1
|
||||
|
||||
const openDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export const db = {
|
||||
async get(key) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.get(key)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
},
|
||||
|
||||
async set(key, value) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
// 使用 JSON.parse(JSON.stringify()) 去除 Proxy 对象,确保存储的是纯数据
|
||||
const rawValue = JSON.parse(JSON.stringify(value))
|
||||
const request = store.put(rawValue, key)
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,73 @@
|
||||
<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'
|
||||
|
||||
const store = useSpreadsheetStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 从 IndexedDB 加载上次的状态
|
||||
store.loadData()
|
||||
})
|
||||
|
||||
const handleToolbarAction = (type: string, payload?: any) => {
|
||||
if (type === 'toggleStyle') store.toggleStyle(payload)
|
||||
else if (type === 'setAlign') store.setStyle('align', payload)
|
||||
else if (type === 'insertImages') {
|
||||
const images = payload as string[]
|
||||
const startR = store.selection.start.r
|
||||
const startC = store.selection.start.c
|
||||
|
||||
// 自动撑开当前行以便预览
|
||||
const currentRow = store.rows.find(r => r.index === startR)
|
||||
if (currentRow && currentRow.height < 100) {
|
||||
currentRow.height = 100
|
||||
}
|
||||
|
||||
// 横向插入图片
|
||||
images.forEach((imgUrl, index) => {
|
||||
const targetC = startC + index
|
||||
if (targetC < store.cols.length) {
|
||||
store.setCell(startR, targetC, { value: imgUrl, type: 'image' })
|
||||
const currentCol = store.cols[targetC]
|
||||
if (currentCol.width < 100) currentCol.width = 100
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (type === 'addRow') store.insertRow(store.selection.start.r + 1)
|
||||
else if (type === 'addCol') store.insertCol(store.selection.start.c + 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
你好
|
||||
<div class="flex flex-col h-full w-full bg-white">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
:current-style="store.currentStyle"
|
||||
@action="handleToolbarAction"
|
||||
/>
|
||||
|
||||
<!-- 公式栏 -->
|
||||
<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>
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 8888,
|
||||
port: 8806,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://127.0.0.1:18007/api/',
|
||||
|
||||
Reference in New Issue
Block a user