1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-06-27 19:35:07 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
Sunwuyuan
28a3e878b7
修复vercel构建问题 2026-06-19 18:02:25 +08:00
Sunwuyuan
0f44a97f83
优化离线使用体验 2026-06-19 17:44:40 +08:00
7 changed files with 956 additions and 76 deletions

View File

@ -29,7 +29,9 @@
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-sonner": "^2.0.9", "vue-sonner": "^2.0.9",
"vuetify": "^3.11.0" "vuetify": "^3.11.0",
"vite": "^5.4.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@ -48,7 +50,6 @@
"unplugin-fonts": "^1.4.0", "unplugin-fonts": "^1.4.0",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"unplugin-vue-router": "^0.18.0", "unplugin-vue-router": "^0.18.0",
"vite": "^5.4.11",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-vue-devtools": "^7.6.8", "vite-plugin-vue-devtools": "^7.6.8",
"vite-plugin-vue-layouts": "^0.11.0", "vite-plugin-vue-layouts": "^0.11.0",

43
pnpm-lock.yaml generated
View File

@ -56,6 +56,9 @@ importers:
uuid: uuid:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.0.0 version: 13.0.0
vite:
specifier: ^5.4.11
version: 5.4.21(sass-embedded@1.93.3)(sass@1.94.2)(terser@5.44.1)
vue: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.25(typescript@5.9.3) version: 3.5.25(typescript@5.9.3)
@ -114,9 +117,6 @@ importers:
unplugin-vue-router: unplugin-vue-router:
specifier: ^0.18.0 specifier: ^0.18.0
version: 0.18.0(@vue/compiler-sfc@3.5.25)(typescript@5.9.3)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) version: 0.18.0(@vue/compiler-sfc@3.5.25)(typescript@5.9.3)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
vite:
specifier: ^5.4.11
version: 5.4.21(sass-embedded@1.93.3)(sass@1.94.2)(terser@5.44.1)
vite-plugin-pwa: vite-plugin-pwa:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(@vite-pwa/assets-generator@1.0.2)(vite@5.4.21(sass-embedded@1.93.3)(sass@1.94.2)(terser@5.44.1))(workbox-build@7.4.0)(workbox-window@7.4.0) version: 1.2.0(@vite-pwa/assets-generator@1.0.2)(vite@5.4.21(sass-embedded@1.93.3)(sass@1.94.2)(terser@5.44.1))(workbox-build@7.4.0)(workbox-window@7.4.0)
@ -904,67 +904,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5': '@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4': '@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4': '@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4': '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4': '@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5': '@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5': '@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5': '@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5': '@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5': '@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5': '@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@ -1061,36 +1073,42 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1': '@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1': '@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1': '@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1': '@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1': '@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1': '@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@ -1206,56 +1224,67 @@ packages:
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.3': '@rollup/rollup-linux-arm-musleabihf@4.53.3':
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.3': '@rollup/rollup-linux-arm64-gnu@4.53.3':
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.3': '@rollup/rollup-linux-arm64-musl@4.53.3':
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.3': '@rollup/rollup-linux-loong64-gnu@4.53.3':
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.3': '@rollup/rollup-linux-ppc64-gnu@4.53.3':
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.3': '@rollup/rollup-linux-riscv64-gnu@4.53.3':
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.3': '@rollup/rollup-linux-riscv64-musl@4.53.3':
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.3': '@rollup/rollup-linux-s390x-gnu@4.53.3':
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.3': '@rollup/rollup-linux-x64-gnu@4.53.3':
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.3': '@rollup/rollup-linux-x64-musl@4.53.3':
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.3': '@rollup/rollup-openharmony-arm64@4.53.3':
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
@ -2957,48 +2986,56 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: glibc
sass-embedded-linux-arm@1.93.3: sass-embedded-linux-arm@1.93.3:
resolution: {integrity: sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==} resolution: {integrity: sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: glibc
sass-embedded-linux-musl-arm64@1.93.3: sass-embedded-linux-musl-arm64@1.93.3:
resolution: {integrity: sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==} resolution: {integrity: sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: musl
sass-embedded-linux-musl-arm@1.93.3: sass-embedded-linux-musl-arm@1.93.3:
resolution: {integrity: sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==} resolution: {integrity: sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: musl
sass-embedded-linux-musl-riscv64@1.93.3: sass-embedded-linux-musl-riscv64@1.93.3:
resolution: {integrity: sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==} resolution: {integrity: sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: musl
sass-embedded-linux-musl-x64@1.93.3: sass-embedded-linux-musl-x64@1.93.3:
resolution: {integrity: sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==} resolution: {integrity: sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: musl
sass-embedded-linux-riscv64@1.93.3: sass-embedded-linux-riscv64@1.93.3:
resolution: {integrity: sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==} resolution: {integrity: sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: glibc
sass-embedded-linux-x64@1.93.3: sass-embedded-linux-x64@1.93.3:
resolution: {integrity: sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==} resolution: {integrity: sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: glibc
sass-embedded-unknown-all@1.93.3: sass-embedded-unknown-all@1.93.3:
resolution: {integrity: sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==} resolution: {integrity: sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==}

270
src/utils/cacheManager.js Normal file
View File

@ -0,0 +1,270 @@
/**
* Cache Manager TTL 的缓存管理器
*
* 封装 IndexedDB 缓存读写 + 过期逻辑
* 缓存条目格式: { data, meta, cacheTimestamp, cacheTTL }
* 存储在 ClassworksDB kv store key 前缀为 _cache:
*/
import { openDB } from "idb";
import { createEmptyMeta } from "./crdtEngine";
import { getSetting } from "./settings";
// Cache key prefix (与旧代码保持一致)
const CACHE_PREFIX = "_cache:";
// 数据库信息
const DB_NAME = "ClassworksDB";
// 默认 TTL: 7 天
const DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000;
/**
* TTL 配置 key 模式匹配
* 顺序匹配第一个命中的生效
* 通配符 * 匹配任意字符
*/
const TTL_CONFIG = [
{ pattern: "*", ttl: DEFAULT_TTL },
];
// --- 内部辅助 ---
/**
* 初始化数据库连接
*/
async function getDB() {
return openDB(DB_NAME, undefined, {
upgrade(db) {
if (!db.objectStoreNames.contains("kv")) {
db.createObjectStore("kv");
}
if (!db.objectStoreNames.contains("system")) {
db.createObjectStore("system");
}
if (!db.objectStoreNames.contains("syncQueue")) {
db.createObjectStore("syncQueue");
}
},
});
}
/**
* 简单 glob 匹配 (* 匹配任意字符)
* @param {string} pattern
* @param {string} str
* @returns {boolean}
*/
function globMatch(pattern, str) {
const regexStr = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$";
return new RegExp(regexStr).test(str);
}
/**
* 判断缓存条目是否为旧格式 ( meta 字段)
* @param {*} entry
* @returns {boolean}
*/
function isLegacyEntry(entry) {
return entry && typeof entry === "object" && !("meta" in entry) && !("cacheTimestamp" in entry);
}
/**
* 判断缓存条目是否为新格式
* @param {*} entry
* @returns {boolean}
*/
function isNewFormatEntry(entry) {
return entry && typeof entry === "object" && "meta" in entry && "cacheTimestamp" in entry;
}
// --- 导出 API ---
/**
* 根据 key 匹配 TTL 配置
* @param {string} key 数据 key (不含 _cache: 前缀)
* @returns {number} TTL 毫秒数
*/
export function getTTLForKey(key) {
for (const { pattern, ttl } of TTL_CONFIG) {
if (globMatch(pattern, key)) {
return ttl;
}
}
return DEFAULT_TTL;
}
/**
* 读取缓存条目
* - 过期则自动删除并返回 null
* - 旧格式自动迁移到新格式
*
* @param {string} key 数据 key (不含前缀)
* @returns {Promise<{ data: *, meta: Object }|null>}
*/
export async function getCacheEntry(key) {
try {
const db = await getDB();
const cacheKey = CACHE_PREFIX + key;
const raw = await db.get("kv", cacheKey);
if (!raw) return null;
// 尝试解析 JSON
let entry;
try {
entry = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return null;
}
// 旧格式迁移
if (isLegacyEntry(entry)) {
const deviceId = getSetting("device.uuid") || "unknown";
const meta = createEmptyMeta(deviceId);
meta.ts = Date.now();
meta.lastSyncedData = entry;
const migrated = {
data: entry,
meta,
cacheTimestamp: Date.now(),
cacheTTL: getTTLForKey(key),
};
// 写回迁移后的格式
await db.put("kv", JSON.stringify(migrated), cacheKey);
return { data: migrated.data, meta: migrated.meta };
}
// 新格式 — 检查是否过期
if (isNewFormatEntry(entry)) {
const age = Date.now() - entry.cacheTimestamp;
if (age > entry.cacheTTL) {
// 过期,删除
await db.delete("kv", cacheKey);
return null;
}
return { data: entry.data, meta: entry.meta };
}
return null;
} catch (error) {
console.warn("cacheManager.getCacheEntry 失败:", error);
return null;
}
}
/**
* 写入缓存条目
* @param {string} key 数据 key (不含前缀)
* @param {*} data 用户数据
* @param {Object} meta CRDT metadata
* @returns {Promise<boolean>}
*/
export async function setCacheEntry(key, data, meta) {
try {
const db = await getDB();
const cacheKey = CACHE_PREFIX + key;
const entry = {
data,
meta,
cacheTimestamp: Date.now(),
cacheTTL: getTTLForKey(key),
};
await db.put("kv", JSON.stringify(entry), cacheKey);
return true;
} catch (error) {
console.warn("cacheManager.setCacheEntry 失败:", error);
return false;
}
}
/**
* 删除缓存条目
* @param {string} key 数据 key (不含前缀)
* @returns {Promise<boolean>}
*/
export async function deleteCacheEntry(key) {
try {
const db = await getDB();
await db.delete("kv", CACHE_PREFIX + key);
return true;
} catch (error) {
console.warn("cacheManager.deleteCacheEntry 失败:", error);
return false;
}
}
/**
* 检查缓存是否未过期
* @param {string} key 数据 key (不含前缀)
* @returns {Promise<boolean>}
*/
export async function isCacheFresh(key) {
try {
const db = await getDB();
const raw = await db.get("kv", CACHE_PREFIX + key);
if (!raw) return false;
let entry;
try {
entry = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return false;
}
if (!isNewFormatEntry(entry)) return false;
return Date.now() - entry.cacheTimestamp <= entry.cacheTTL;
} catch {
return false;
}
}
/**
* 清理所有过期的 _cache: 条目
* 在启动时和同步完成后调用
* @returns {Promise<number>} 删除的条目数
*/
export async function cleanupExpiredEntries() {
let cleaned = 0;
try {
const db = await getDB();
const tx = db.transaction("kv", "readwrite");
const store = tx.objectStore("kv");
const allKeys = await store.getAllKeys();
for (const storeKey of allKeys) {
if (!storeKey.startsWith(CACHE_PREFIX)) continue;
const raw = await store.get(storeKey);
if (!raw) continue;
let entry;
try {
entry = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
continue;
}
if (isNewFormatEntry(entry)) {
const age = Date.now() - entry.cacheTimestamp;
if (age > entry.cacheTTL) {
await store.delete(storeKey);
cleaned++;
}
}
}
await tx.done;
} catch (error) {
console.warn("cacheManager.cleanupExpiredEntries 失败:", error);
}
return cleaned;
}
/**
* 获取缓存前缀 (供外部使用)
*/
export { CACHE_PREFIX };

305
src/utils/crdtEngine.js Normal file
View File

@ -0,0 +1,305 @@
/**
* CRDT Engine 纯函数冲突解决模块
*
* 基于向量时钟的无冲突复制数据类型 (CRDT) 实现
* 所有函数无副作用不依赖 IndexedDB 或网络
*
* 合并策略:
* - 对象: 按字段 LWW (Last-Writer-Wins)使用 _fieldTs 字段级时间戳
* - 数组: identity 合并union 语义
* - 原始类型: LWW时间戳相同时 deviceId 字典序决定
*/
// --- 向量时钟操作 ---
/**
* 创建空的 metadata 对象
* @param {string} deviceId 本设备标识符
* @returns {Object} 初始 metadata
*/
export function createEmptyMeta(deviceId) {
return {
vc: { [deviceId]: 0 },
ts: 0,
deviceId,
_fieldTs: {},
lastSyncedData: null,
lastSyncedTs: 0,
lastSyncedVc: { [deviceId]: 0 },
};
}
/**
* 递增设备时钟返回新的 metadata (不可变)
* @param {Object} meta 当前 metadata
* @param {string} deviceId 本设备标识符
* @returns {Object} 递增后的新 metadata
*/
export function bumpClock(meta, deviceId) {
const newVc = { ...meta.vc };
newVc[deviceId] = (newVc[deviceId] || 0) + 1;
return {
...meta,
vc: newVc,
ts: Date.now(),
deviceId,
};
}
/**
* 合并两个向量时钟取各分量的 max
* @param {Object} vcA
* @param {Object} vcB
* @returns {Object} 合并后的向量时钟
*/
export function mergeClocks(vcA, vcB) {
const result = { ...vcA };
for (const [node, count] of Object.entries(vcB)) {
result[node] = Math.max(result[node] || 0, count);
}
return result;
}
/**
* 比较两个版本的向量时钟
* @param {Object} metaA
* @param {Object} metaB
* @returns {"A_NEWER"|"B_NEWER"|"CONCURRENT"|"EQUAL"}
*/
export function compareVersions(metaA, metaB) {
const vcA = metaA.vc || {};
const vcB = metaB.vc || {};
// 收集所有节点
const allNodes = new Set([...Object.keys(vcA), ...Object.keys(vcB)]);
let aGreater = false;
let bGreater = false;
for (const node of allNodes) {
const a = vcA[node] || 0;
const b = vcB[node] || 0;
if (a > b) aGreater = true;
if (b > a) bGreater = true;
if (aGreater && bGreater) return "CONCURRENT";
}
if (!aGreater && !bGreater) return "EQUAL";
if (aGreater) return "A_NEWER";
return "B_NEWER";
}
// --- 数据合并 ---
/**
* 判断是否为纯对象 (非数组 null)
*/
function isPlainObject(val) {
return val !== null && typeof val === "object" && !Array.isArray(val);
}
/**
* DJB2 哈希用于轻量数据比较
* @param {*} data JSON 可序列化数据
* @returns {string} 十六进制哈希字符串
*/
export function computeDataHash(data) {
const str = typeof data === "string" ? data : JSON.stringify(data);
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(16);
}
/**
* 合并字段级时间戳
* @param {Object} fieldTsA
* @param {Object} fieldTsB
* @returns {Object}
*/
function mergeFieldTimestamps(fieldTsA, fieldTsB) {
if (!fieldTsA && !fieldTsB) return {};
if (!fieldTsA) return { ...fieldTsB };
if (!fieldTsB) return { ...fieldTsA };
const result = { ...fieldTsA };
for (const [path, infoB] of Object.entries(fieldTsB)) {
const infoA = result[path];
if (!infoA || infoB.ts > infoA.ts || (infoB.ts === infoA.ts && infoB.deviceId < infoA.deviceId)) {
result[path] = infoB;
}
}
return result;
}
/**
* 检测数组的 identity 函数
* 优先级: id > name > key > JSON.stringify
*/
function detectIdentityFn(arr) {
if (!arr || arr.length === 0) return null;
const first = arr[0];
if (isPlainObject(first)) {
if ("id" in first) return (item) => (isPlainObject(item) ? item.id : JSON.stringify(item));
if ("name" in first) return (item) => (isPlainObject(item) ? item.name : JSON.stringify(item));
if ("key" in first) return (item) => (isPlainObject(item) ? item.key : JSON.stringify(item));
}
return null;
}
/**
* 合并两个数组 union 语义 identity 去重
* @param {Array} local
* @param {Object} localMeta
* @param {Array} remote
* @returns {Array} 合并后的数组
*/
function mergeArrays(local, localMeta, remote) {
const identityFn =
detectIdentityFn(local) ||
detectIdentityFn(remote) ||
((item) => JSON.stringify(item));
const localMap = new Map();
const remoteMap = new Map();
local.forEach((item) => {
const id = identityFn(item);
if (!localMap.has(id)) localMap.set(id, item);
});
remote.forEach((item) => {
const id = identityFn(item);
if (!remoteMap.has(id)) remoteMap.set(id, item);
});
const result = [];
const seen = new Set();
// 保持本地顺序,本地有的以本地为准
for (const [id, item] of localMap) {
if (seen.has(id)) continue;
seen.add(id);
result.push(item);
}
// 追加远程独有的
for (const [id, item] of remoteMap) {
if (!seen.has(id)) {
seen.add(id);
result.push(item);
}
}
return result;
}
/**
* 合并两个对象 按字段 LWW
* @param {Object} local
* @param {Object} localMeta
* @param {Object} remote
* @param {Object} remoteMeta
* @returns {Object}
*/
function mergeObjects(local, localMeta, remote, remoteMeta) {
const result = {};
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
for (const key of allKeys) {
const localHas = key in local;
const remoteHas = key in remote;
if (localHas && !remoteHas) {
result[key] = local[key];
} else if (!localHas && remoteHas) {
result[key] = remote[key];
} else {
// 两边都有 — 用字段级时间戳决定
const localFieldInfo = localMeta._fieldTs?.[key] || {
ts: localMeta.ts,
deviceId: localMeta.deviceId,
};
const remoteFieldInfo = remoteMeta._fieldTs?.[key] || {
ts: remoteMeta.ts,
deviceId: remoteMeta.deviceId,
};
if (localFieldInfo.ts > remoteFieldInfo.ts) {
result[key] = local[key];
} else if (remoteFieldInfo.ts > localFieldInfo.ts) {
result[key] = remote[key];
} else {
// 时间戳相同 — deviceId 字典序决定
result[key] =
localFieldInfo.deviceId <= remoteFieldInfo.deviceId
? local[key]
: remote[key];
}
}
}
return result;
}
/**
* CRDT 合并入口 根据数据类型选择合并策略
*
* @param {*} localData 本地数据
* @param {Object} localMeta 本地 metadata ( vc, ts, deviceId, _fieldTs)
* @param {*} remoteData 远程数据
* @param {Object} remoteMeta 远程 metadata
* @returns {{ data: *, meta: Object }} 合并后的数据和 metadata
*/
export function mergeValues(localData, localMeta, remoteData, remoteMeta) {
const mergedVc = mergeClocks(localMeta.vc || {}, remoteMeta.vc || {});
const mergedTs = Math.max(localMeta.ts || 0, remoteMeta.ts || 0);
const mergedFieldTs = mergeFieldTimestamps(
localMeta._fieldTs,
remoteMeta._fieldTs,
);
let mergedData;
if (isPlainObject(localData) && isPlainObject(remoteData)) {
mergedData = mergeObjects(
localData,
localMeta,
remoteData,
remoteMeta,
);
} else if (Array.isArray(localData) && Array.isArray(remoteData)) {
mergedData = mergeArrays(localData, localMeta, remoteData);
} else if (
typeof localData === typeof remoteData &&
typeof localData !== "object"
) {
// 原始类型: LWW
if ((localMeta.ts || 0) > (remoteMeta.ts || 0)) {
mergedData = localData;
} else if ((remoteMeta.ts || 0) > (localMeta.ts || 0)) {
mergedData = remoteData;
} else {
mergedData =
(localMeta.deviceId || "") <= (remoteMeta.deviceId || "")
? localData
: remoteData;
}
} else {
// 类型不同 — LWW
mergedData = (localMeta.ts || 0) >= (remoteMeta.ts || 0) ? localData : remoteData;
}
return {
data: mergedData,
meta: {
vc: mergedVc,
ts: mergedTs,
deviceId: mergedTs === (localMeta.ts || 0) ? localMeta.deviceId : remoteMeta.deviceId,
_fieldTs: mergedFieldTs,
lastSyncedData: remoteMeta.lastSyncedData ?? localMeta.lastSyncedData ?? null,
lastSyncedTs: Math.max(localMeta.lastSyncedTs || 0, remoteMeta.lastSyncedTs || 0),
lastSyncedVc: mergedVc,
},
};
}

View File

@ -1,18 +1,31 @@
import {kvLocalProvider} from "./providers/kvLocalProvider"; import { kvLocalProvider } from "./providers/kvLocalProvider";
import {kvServerProvider} from "./providers/kvServerProvider"; import { kvServerProvider } from "./providers/kvServerProvider";
import {getSetting, setSetting} from "./settings"; import { getSetting, setSetting } from "./settings";
import {getEffectiveServerUrl} from "./serverRotation"; import { getEffectiveServerUrl } from "./serverRotation";
import {
createEmptyMeta,
bumpClock,
mergeValues,
} from "./crdtEngine";
import {
getCacheEntry,
setCacheEntry,
CACHE_PREFIX,
} from "./cacheManager";
import {
initSmartSync,
destroySmartSync,
flushAll,
triggerSyncAfterSuccess,
} from "./smartSyncManager";
export const formatResponse = (data) => data; export const formatResponse = (data) => data;
export const formatError = (message, code = "UNKNOWN_ERROR") => ({ export const formatError = (message, code = "UNKNOWN_ERROR") => ({
success: false, success: false,
error: {code, message}, error: { code, message },
}); });
// Cache key prefix to avoid collision with kv-local mode data
const CACHE_PREFIX = "_cache:";
function isServerError(result) { function isServerError(result) {
return result && result.success === false; return result && result.success === false;
} }
@ -21,50 +34,32 @@ function isNetworkError(result) {
return isServerError(result) && result.error?.code === "NETWORK_ERROR"; return isServerError(result) && result.error?.code === "NETWORK_ERROR";
} }
// --- Sync manager: flushes queued writes when back online --- /**
* 获取设备 ID用于 CRDT 向量时钟节点标识
let _onlineHandler = null; */
let _flushing = false; function getDeviceId() {
return getSetting("device.uuid") || "unknown";
async function flushSyncQueue() {
if (_flushing) return;
_flushing = true;
try {
const queueResult = await kvLocalProvider.getSyncQueue();
if (queueResult.success === false || !Array.isArray(queueResult) || queueResult.length === 0) {
return;
}
for (const entry of queueResult) {
try {
const result = await kvServerProvider.saveData(entry.key, entry.data);
if (result.success !== false) {
await kvLocalProvider.removeFromSyncQueue(entry.key);
}
} catch {
// If a single item fails, stop — will retry on next online event
break;
}
}
} finally {
_flushing = false;
}
} }
/**
* 规范化服务器返回的数据
* 某些端点 ( Bearer token 认证) 返回 {value: [...]} 包装格式
* 统一解包为原始数据保证缓存比较的一致性
* @param {*} data 服务器返回的原始数据
* @returns {*} 规范化后的数据
*/
function normalizeServerData(data) {
if (data && typeof data === "object" && !Array.isArray(data) && "value" in data) {
return data.value;
}
return data;
}
// --- Sync manager: 向后兼容的导出 ---
export const syncManager = { export const syncManager = {
init() { init: initSmartSync,
if (_onlineHandler) return; destroy: destroySmartSync,
_onlineHandler = () => flushSyncQueue(); flushNow: flushAll,
window.addEventListener("online", _onlineHandler);
// Attempt flush on startup in case items were queued before last exit
if (navigator.onLine) flushSyncQueue();
},
destroy() {
if (_onlineHandler) {
window.removeEventListener("online", _onlineHandler);
_onlineHandler = null;
}
},
flushNow: flushSyncQueue,
}; };
// Helper: check if we should use the server provider // Helper: check if we should use the server provider
@ -81,21 +76,95 @@ export default {
return kvLocalProvider.loadData(key); return kvLocalProvider.loadData(key);
} }
// Server mode: network-first with cache fallback // Server mode: network-first with CRDT-aware cache
const result = await kvServerProvider.loadData(key); const rawResult = await kvServerProvider.loadData(key);
// 规范化: 某些端点返回 {value: [...]} 包装格式,统一解包
const result = normalizeServerData(rawResult);
if (!isNetworkError(result)) { if (!isNetworkError(result)) {
// Success or non-network error (e.g. NOT_FOUND) — cache on success // 服务器返回成功或非网络错误 (如 NOT_FOUND)
if (result.success !== false) { if (result.success !== false) {
kvLocalProvider.saveData(CACHE_PREFIX + key, result); // 有效数据 — 与本地缓存进行 CRDT 比较
const cacheEntry = await getCacheEntry(key);
const deviceId = getDeviceId();
if (cacheEntry) {
const cachedData = normalizeServerData(cacheEntry.data);
const localDataStr = JSON.stringify(cachedData);
const serverDataStr = JSON.stringify(result);
const lastSyncedStr = JSON.stringify(normalizeServerData(cacheEntry.meta.lastSyncedData) ?? null);
if (serverDataStr === localDataStr) {
// 数据完全相同 — 无冲突,直接返回本地
return cachedData;
}
if (serverDataStr !== lastSyncedStr) {
// 服务器数据与上次同步快照不同 — 另一台设备写入了新数据
const localVc = cacheEntry.meta.vc || {};
const lastSyncedVc = cacheEntry.meta.lastSyncedVc || {};
const hasLocalChanges = (localVc[deviceId] || 0) > (lastSyncedVc[deviceId] || 0);
if (hasLocalChanges) {
// 本地也有未同步的更改 — CRDT 合并
const serverMeta = createEmptyMeta("server");
serverMeta.ts = Date.now();
const merged = mergeValues(
cachedData,
cacheEntry.meta,
result,
serverMeta,
);
await setCacheEntry(key, merged.data, merged.meta);
// 推送合并结果到服务器 (fire-and-forget)
kvServerProvider.saveData(key, merged.data);
return merged.data;
} else {
// 本地无未同步更改 — 采用服务器版本
const meta = createEmptyMeta(deviceId);
meta.ts = Date.now();
meta.lastSyncedData = result;
meta.lastSyncedTs = Date.now();
meta.lastSyncedVc = { ...meta.vc };
await setCacheEntry(key, result, meta);
return result;
}
} else {
// 服务器数据 === 上次同步快照,但 ≠ 本地数据
// 说明本地有未推送的更改,返回本地数据
return cachedData;
}
} else {
// 无本地缓存 — 首次获取
const meta = createEmptyMeta(deviceId);
meta.ts = Date.now();
meta.lastSyncedData = result;
meta.lastSyncedTs = Date.now();
meta.lastSyncedVc = { ...meta.vc };
await setCacheEntry(key, result, meta);
return result;
}
} }
return result; return result;
} }
// Network error — try local cache // 网络错误 — 从缓存兜底
const cached = await kvLocalProvider.loadData(CACHE_PREFIX + key); const cached = await getCacheEntry(key);
if (cached.success !== false) { if (cached) {
return {...cached, fromCache: true}; const data = normalizeServerData(cached.data);
// 直接在数据对象上添加 fromCache 标记 (保持数组类型不变)
if (typeof data === "object" && data !== null) {
data.fromCache = true;
}
return data;
}
// 兼容旧格式缓存
const legacyCached = await kvLocalProvider.loadData(CACHE_PREFIX + key);
if (legacyCached.success !== false) {
return { ...legacyCached, fromCache: true };
} }
return result; return result;
@ -106,20 +175,45 @@ export default {
return kvLocalProvider.saveData(key, data); return kvLocalProvider.saveData(key, data);
} }
// Server mode: write-through — persist locally first const deviceId = getDeviceId();
await kvLocalProvider.saveData(CACHE_PREFIX + key, data);
// 读取现有缓存条目获取当前向量时钟
const existingEntry = await getCacheEntry(key);
let meta;
if (existingEntry) {
meta = bumpClock(existingEntry.meta, deviceId);
} else {
meta = bumpClock(createEmptyMeta(deviceId), deviceId);
}
// Write-through: 先写入本地缓存 (含 CRDT metadata)
await setCacheEntry(key, data, meta);
const result = await kvServerProvider.saveData(key, data); const result = await kvServerProvider.saveData(key, data);
if (result.success !== false) { if (result.success !== false) {
// Server save succeeded — remove from sync queue if present // 服务器保存成功 — 更新 lastSynced 快照
meta.lastSyncedData = data;
meta.lastSyncedTs = Date.now();
meta.lastSyncedVc = { ...meta.vc };
await setCacheEntry(key, data, meta);
await kvLocalProvider.removeFromSyncQueue(key); await kvLocalProvider.removeFromSyncQueue(key);
// 智能同步: 刷新其他队列中的更改
triggerSyncAfterSuccess();
return result; return result;
} }
// Server save failed — queue for later sync // 服务器保存失败 — 加入同步队列 (含 CRDT metadata)
await kvLocalProvider.addToSyncQueue({key, data, timestamp: Date.now()}); await kvLocalProvider.addToSyncQueue({
return {success: true, queuedForSync: true}; key,
data,
timestamp: Date.now(),
meta,
});
return { success: true, queuedForSync: true };
}, },
loadKeys: async (options = {}) => { loadKeys: async (options = {}) => {
@ -140,7 +234,7 @@ export default {
async getKeyCloudUrl(key, options = {}) { async getKeyCloudUrl(key, options = {}) {
const { const {
migrateFromLocal = true, migrateFromLocal = true,
autoConfigureCloud = true autoConfigureCloud = true,
} = options; } = options;
try { try {
@ -217,27 +311,24 @@ export default {
// 获取认证token // 获取认证token
const authtoken = getSetting("server.kvToken"); const authtoken = getSetting("server.kvToken");
// 构建云端访问URL // 构建云端访问URL
let url = `${serverUrl}/kv/${key}?token=${authtoken}`; const url = `${serverUrl}/kv/${key}?token=${authtoken}`;
return { return {
success: true, success: true,
url, url,
migrated, migrated,
configured configured,
}; };
} catch (error) { } catch (error) {
console.error('获取键云端地址时出错:', error); console.error("获取键云端地址时出错:", error);
return formatError( return formatError(
error.message || "获取键云端地址失败", error.message || "获取键云端地址失败",
"CLOUD_URL_ERROR" "CLOUD_URL_ERROR",
); );
} }
}, },
}; };
export const ErrorCodes = { export const ErrorCodes = {
NOT_FOUND: "数据不存在", NOT_FOUND: "数据不存在",
NETWORK_ERROR: "网络连接失败", NETWORK_ERROR: "网络连接失败",

View File

@ -155,4 +155,32 @@ export const kvLocalProvider = {
return formatError("删除同步队列项失败:" + error); return formatError("删除同步队列项失败:" + error);
} }
}, },
// --- Prefix-based operations for cache management ---
/**
* 删除指定前缀的所有键
* @param {string} prefix 键名前缀
* @returns {Promise<number>} 删除的键数
*/
async deleteByPrefix(prefix) {
try {
const db = await initDB();
const tx = db.transaction("kv", "readwrite");
const store = tx.objectStore("kv");
const allKeys = await store.getAllKeys();
let deleted = 0;
for (const key of allKeys) {
if (key.startsWith(prefix)) {
await store.delete(key);
deleted++;
}
}
await tx.done;
return deleted;
} catch (error) {
console.warn("kvLocalProvider.deleteByPrefix 失败:", error);
return 0;
}
},
}; };

View File

@ -0,0 +1,148 @@
/**
* Smart Sync Manager 增强同步管理器
*
* 替代 dataProvider.js 中内联的 flushSyncQueue
* 功能:
* - 网络恢复时自动刷新同步队列
* - 任意云端操作成功后若队列有未同步条目自动触发全部同步
* - 同步成功后更新本地缓存的 lastSyncedData/lastSyncedVc 快照
* - 启动时清理过期缓存
*/
import { kvLocalProvider } from "./providers/kvLocalProvider";
import { kvServerProvider } from "./providers/kvServerProvider";
import { cleanupExpiredEntries, getCacheEntry, setCacheEntry } from "./cacheManager";
import { createEmptyMeta } from "./crdtEngine";
import { getSetting } from "./settings";
let _onlineHandler = null;
let _flushing = false;
/**
* 刷新同步队列 逐条重放失败则停止
* @param {Object} [options]
* @param {boolean} [options.silent=false] 静默模式不输出日志
* @returns {Promise<{ synced: number, failed: number }>}
*/
export async function flushAll(options = {}) {
if (_flushing) return { synced: 0, failed: 0 };
_flushing = true;
let synced = 0;
let failed = 0;
try {
const queueResult = await kvLocalProvider.getSyncQueue();
if (queueResult.success === false || !Array.isArray(queueResult) || queueResult.length === 0) {
return { synced: 0, failed: 0 };
}
for (const entry of queueResult) {
try {
const result = await kvServerProvider.saveData(entry.key, entry.data);
if (result.success !== false) {
// 同步成功 — 从队列移除
await kvLocalProvider.removeFromSyncQueue(entry.key);
// 更新本地缓存的 lastSynced 快照
if (entry.meta) {
const deviceId = getSetting("device.uuid") || "unknown";
const existingEntry = await getCacheEntry(entry.key);
if (existingEntry) {
const meta = { ...existingEntry.meta };
meta.lastSyncedData = entry.data;
meta.lastSyncedTs = Date.now();
meta.lastSyncedVc = { ...meta.vc };
await setCacheEntry(entry.key, existingEntry.data, meta);
} else {
// 缓存条目已过期或不存在,重建
const meta = entry.meta || createEmptyMeta(deviceId);
meta.lastSyncedData = entry.data;
meta.lastSyncedTs = Date.now();
meta.lastSyncedVc = { ...meta.vc };
await setCacheEntry(entry.key, entry.data, meta);
}
}
synced++;
} else {
// 服务器返回错误 (非网络错误) — 跳过此项继续
failed++;
if (!options.silent) {
console.warn(`smartSync: 跳过 key=${entry.key}, 服务器错误:`, result.error?.message);
}
}
} catch {
// 网络错误 — 停止处理,下次重试
failed++;
break;
}
}
// 清理过期缓存
if (synced > 0) {
cleanupExpiredEntries();
}
} finally {
_flushing = false;
}
if (!options.silent && synced > 0) {
console.log(`smartSync: 同步完成,成功 ${synced} 条,失败 ${failed}`);
}
return { synced, failed };
}
/**
* 触发条件同步 任意云端操作成功后调用
* 仅在队列中有条目时执行
* @returns {Promise<void>}
*/
export async function triggerSyncAfterSuccess() {
try {
const queueResult = await kvLocalProvider.getSyncQueue();
if (queueResult.success === false || !Array.isArray(queueResult)) return;
if (queueResult.length === 0) return;
// 有未同步条目,触发全部同步
flushAll({ silent: true });
} catch {
// 静默失败
}
}
/**
* 初始化智能同步管理器
* - 注册 online 事件监听
* - 启动时清理过期缓存
* - 启动时尝试刷新队列
*/
export function initSmartSync() {
if (_onlineHandler) return;
_onlineHandler = () => flushAll();
window.addEventListener("online", _onlineHandler);
// 启动时清理过期缓存
cleanupExpiredEntries();
// 启动时尝试刷新队列
if (navigator.onLine) {
flushAll();
}
}
/**
* 销毁智能同步管理器
* 移除事件监听
*/
export function destroySmartSync() {
if (_onlineHandler) {
window.removeEventListener("online", _onlineHandler);
_onlineHandler = null;
}
}