This commit is contained in:
johnathan 2025-06-20 11:12:32 +08:00
parent 0948413460
commit 64b41f5841
21 changed files with 1263 additions and 222 deletions

View File

@ -35,6 +35,7 @@
"@types/node": "^22.10.5",
"@types/webextension-polyfill": "^0.12.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.3.0",
"chokidar": "^4.0.3",

626
pnpm-lock.yaml generated
View File

@ -25,6 +25,9 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.2))
'@vitejs/plugin-vue-jsx':
specifier: ^4.2.0
version: 4.2.0(vite@6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.2))
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
@ -93,7 +96,7 @@ importers:
version: 22.1.0(@vue/compiler-sfc@3.5.13)
unplugin-vue-components:
specifier: ^28.4.1
version: 28.4.1(@babel/parser@7.27.0)(vue@3.5.13(typescript@5.8.2))
version: 28.4.1(@babel/parser@7.27.5)(vue@3.5.13(typescript@5.8.2))
vite:
specifier: ^6.2.4
version: 6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1)
@ -120,6 +123,13 @@ importers:
version: 0.12.0
packages:
'@ampproject/remapping@2.3.0':
resolution:
{
integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==,
}
engines: { node: '>=6.0.0' }
'@antfu/install-pkg@1.0.0':
resolution:
{
@ -145,6 +155,110 @@ packages:
}
engines: { node: '>=6.9.0' }
'@babel/code-frame@7.27.1':
resolution:
{
integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==,
}
engines: { node: '>=6.9.0' }
'@babel/compat-data@7.27.5':
resolution:
{
integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==,
}
engines: { node: '>=6.9.0' }
'@babel/core@7.27.4':
resolution:
{
integrity: sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==,
}
engines: { node: '>=6.9.0' }
'@babel/generator@7.27.5':
resolution:
{
integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-annotate-as-pure@7.27.3':
resolution:
{
integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-compilation-targets@7.27.2':
resolution:
{
integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-create-class-features-plugin@7.27.1':
resolution:
{
integrity: sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-member-expression-to-functions@7.27.1':
resolution:
{
integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-module-imports@7.27.1':
resolution:
{
integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-module-transforms@7.27.3':
resolution:
{
integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-optimise-call-expression@7.27.1':
resolution:
{
integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-plugin-utils@7.27.1':
resolution:
{
integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-replace-supers@7.27.1':
resolution:
{
integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
resolution:
{
integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-string-parser@7.25.9':
resolution:
{
@ -152,6 +266,13 @@ packages:
}
engines: { node: '>=6.9.0' }
'@babel/helper-string-parser@7.27.1':
resolution:
{
integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-validator-identifier@7.25.9':
resolution:
{
@ -159,6 +280,27 @@ packages:
}
engines: { node: '>=6.9.0' }
'@babel/helper-validator-identifier@7.27.1':
resolution:
{
integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==,
}
engines: { node: '>=6.9.0' }
'@babel/helper-validator-option@7.27.1':
resolution:
{
integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==,
}
engines: { node: '>=6.9.0' }
'@babel/helpers@7.27.6':
resolution:
{
integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==,
}
engines: { node: '>=6.9.0' }
'@babel/parser@7.27.0':
resolution:
{
@ -167,6 +309,41 @@ packages:
engines: { node: '>=6.0.0' }
hasBin: true
'@babel/parser@7.27.5':
resolution:
{
integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==,
}
engines: { node: '>=6.0.0' }
hasBin: true
'@babel/plugin-syntax-jsx@7.27.1':
resolution:
{
integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.27.1':
resolution:
{
integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-typescript@7.27.1':
resolution:
{
integrity: sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==,
}
engines: { node: '>=6.9.0' }
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.26.10':
resolution:
{
@ -174,6 +351,20 @@ packages:
}
engines: { node: '>=6.9.0' }
'@babel/template@7.27.2':
resolution:
{
integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==,
}
engines: { node: '>=6.9.0' }
'@babel/traverse@7.27.4':
resolution:
{
integrity: sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==,
}
engines: { node: '>=6.9.0' }
'@babel/types@7.27.0':
resolution:
{
@ -181,6 +372,13 @@ packages:
}
engines: { node: '>=6.9.0' }
'@babel/types@7.27.6':
resolution:
{
integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==,
}
engines: { node: '>=6.9.0' }
'@bufbuild/protobuf@2.2.3':
resolution:
{
@ -826,12 +1024,39 @@ packages:
}
engines: { node: '>=12' }
'@jridgewell/gen-mapping@0.3.8':
resolution:
{
integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==,
}
engines: { node: '>=6.0.0' }
'@jridgewell/resolve-uri@3.1.2':
resolution:
{
integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==,
}
engines: { node: '>=6.0.0' }
'@jridgewell/set-array@1.2.1':
resolution:
{
integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==,
}
engines: { node: '>=6.0.0' }
'@jridgewell/sourcemap-codec@1.5.0':
resolution:
{
integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==,
}
'@jridgewell/trace-mapping@0.3.25':
resolution:
{
integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==,
}
'@juggle/resize-observer@3.4.0':
resolution:
{
@ -899,6 +1124,12 @@ packages:
}
engines: { node: '>=12' }
'@rolldown/pluginutils@1.0.0-beta.17':
resolution:
{
integrity: sha512-i6p5fc1+lAmR3OHmNlv7/3PIY3EtuUu4kVARjkid38p7cmyIyqr0QFnA+k3xoB06wQUpBA91H1HFlRreZ2v5oA==,
}
'@rollup/rollup-android-arm-eabi@4.39.0':
resolution:
{
@ -1160,6 +1391,16 @@ packages:
integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==,
}
'@vitejs/plugin-vue-jsx@4.2.0':
resolution:
{
integrity: sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw==,
}
engines: { node: ^18.0.0 || >=20.0.0 }
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.0.0
'@vitejs/plugin-vue@5.2.1':
resolution:
{
@ -1220,6 +1461,31 @@ packages:
integrity: sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==,
}
'@vue/babel-helper-vue-transform-on@1.4.0':
resolution:
{
integrity: sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==,
}
'@vue/babel-plugin-jsx@1.4.0':
resolution:
{
integrity: sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA==,
}
peerDependencies:
'@babel/core': ^7.0.0-0
peerDependenciesMeta:
'@babel/core':
optional: true
'@vue/babel-plugin-resolve-type@1.4.0':
resolution:
{
integrity: sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ==,
}
peerDependencies:
'@babel/core': ^7.0.0-0
'@vue/compiler-core@3.5.13':
resolution:
{
@ -1659,6 +1925,14 @@ packages:
}
engines: { node: '>=8' }
browserslist@4.25.0:
resolution:
{
integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==,
}
engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 }
hasBin: true
buffer-builder@0.2.0:
resolution:
{
@ -1752,6 +2026,12 @@ packages:
}
engines: { node: '>=16' }
caniuse-lite@1.0.30001723:
resolution:
{
integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==,
}
chai@5.2.0:
resolution:
{
@ -2014,6 +2294,12 @@ packages:
}
engines: { node: '>=18' }
convert-source-map@2.0.0:
resolution:
{
integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==,
}
core-util-is@1.0.3:
resolution:
{
@ -2371,6 +2657,12 @@ packages:
engines: { node: '>=14' }
hasBin: true
electron-to-chromium@1.5.170:
resolution:
{
integrity: sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==,
}
emittery@1.1.0:
resolution:
{
@ -2905,6 +3197,13 @@ packages:
}
hasBin: true
gensync@1.0.0-beta.2:
resolution:
{
integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==,
}
engines: { node: '>=6.9.0' }
get-caller-file@2.0.5:
resolution:
{
@ -3017,6 +3316,13 @@ packages:
}
engines: { node: '>=18' }
globals@11.12.0:
resolution:
{
integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==,
}
engines: { node: '>=4' }
globals@13.24.0:
resolution:
{
@ -3691,6 +3997,14 @@ packages:
canvas:
optional: true
jsesc@3.1.0:
resolution:
{
integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==,
}
engines: { node: '>=6' }
hasBin: true
json-buffer@3.0.1:
resolution:
{
@ -3740,6 +4054,14 @@ packages:
integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==,
}
json5@2.2.3:
resolution:
{
integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==,
}
engines: { node: '>=6' }
hasBin: true
jsonfile@6.1.0:
resolution:
{
@ -4001,6 +4323,12 @@ packages:
}
engines: { node: 20 || >=22 }
lru-cache@5.1.1:
resolution:
{
integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==,
}
magic-string@0.30.17:
resolution:
{
@ -4208,6 +4536,12 @@ packages:
integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==,
}
node-releases@2.0.19:
resolution:
{
integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==,
}
node-rsa@1.1.1:
resolution:
{
@ -5210,6 +5544,13 @@ packages:
}
hasBin: true
semver@6.3.1:
resolution:
{
integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==,
}
hasBin: true
semver@7.6.3:
resolution:
{
@ -6012,6 +6353,15 @@ packages:
}
engines: { node: '>=4' }
update-browserslist-db@1.1.3:
resolution:
{
integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==,
}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
update-notifier@7.3.1:
resolution:
{
@ -6463,6 +6813,12 @@ packages:
}
engines: { node: '>=10' }
yallist@3.1.1:
resolution:
{
integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==,
}
yaml@2.7.1:
resolution:
{
@ -6519,6 +6875,11 @@ packages:
engines: { node: '>= 10' }
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@antfu/install-pkg@1.0.0':
dependencies:
package-manager-detector: 0.2.11
@ -6540,23 +6901,188 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.27.5': {}
'@babel/core@7.27.4':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.27.1
'@babel/generator': 7.27.5
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4)
'@babel/helpers': 7.27.6
'@babel/parser': 7.27.5
'@babel/template': 7.27.2
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
convert-source-map: 2.0.0
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/generator@7.27.5':
dependencies:
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
jsesc: 3.1.0
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.27.6
'@babel/helper-compilation-targets@7.27.2':
dependencies:
'@babel/compat-data': 7.27.5
'@babel/helper-validator-option': 7.27.1
browserslist: 4.25.0
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-member-expression-to-functions': 7.27.1
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/core@7.27.4)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/traverse': 7.27.4
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.27.3(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.27.4
transitivePeerDependencies:
- supports-color
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.27.6
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-replace-supers@7.27.1(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-member-expression-to-functions': 7.27.1
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/traverse': 7.27.4
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
transitivePeerDependencies:
- supports-color
'@babel/helper-string-parser@7.25.9': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.27.6':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.27.6
'@babel/parser@7.27.0':
dependencies:
'@babel/types': 7.27.0
'@babel/parser@7.27.5':
dependencies:
'@babel/types': 7.27.6
'@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-typescript@7.27.1(@babel/core@7.27.4)':
dependencies:
'@babel/core': 7.27.4
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.27.4)
'@babel/helper-plugin-utils': 7.27.1
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.4)
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.26.10':
dependencies:
regenerator-runtime: 0.14.1
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
'@babel/traverse@7.27.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.27.5
'@babel/parser': 7.27.5
'@babel/template': 7.27.2
'@babel/types': 7.27.6
debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
'@babel/types@7.27.0':
dependencies:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/types@7.27.6':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@bufbuild/protobuf@2.2.3': {}
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
@ -6851,8 +7377,23 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@juggle/resize-observer@3.4.0': {}
'@mdn/browser-compat-data@5.7.3': {}
@ -6886,6 +7427,8 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13
'@rolldown/pluginutils@1.0.0-beta.17': {}
'@rollup/rollup-android-arm-eabi@4.39.0':
optional: true
@ -6987,6 +7530,17 @@ snapshots:
'@ungap/structured-clone@1.2.1': {}
'@vitejs/plugin-vue-jsx@4.2.0(vite@6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@babel/core': 7.27.4
'@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.27.4)
'@rolldown/pluginutils': 1.0.0-beta.17
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.27.4)
vite: 6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1)
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@5.2.1(vite@6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.2))':
dependencies:
vite: 6.2.4(@types/node@22.10.5)(sass-embedded@1.86.2)(tsx@4.19.2)(yaml@2.7.1)
@ -7032,6 +7586,35 @@ snapshots:
loupe: 3.1.3
tinyrainbow: 2.0.0
'@vue/babel-helper-vue-transform-on@1.4.0': {}
'@vue/babel-plugin-jsx@1.4.0(@babel/core@7.27.4)':
dependencies:
'@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.4)
'@babel/template': 7.27.2
'@babel/traverse': 7.27.4
'@babel/types': 7.27.0
'@vue/babel-helper-vue-transform-on': 1.4.0
'@vue/babel-plugin-resolve-type': 1.4.0(@babel/core@7.27.4)
'@vue/shared': 3.5.13
optionalDependencies:
'@babel/core': 7.27.4
transitivePeerDependencies:
- supports-color
'@vue/babel-plugin-resolve-type@1.4.0(@babel/core@7.27.4)':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/core': 7.27.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1
'@babel/parser': 7.27.0
'@vue/compiler-sfc': 3.5.13
transitivePeerDependencies:
- supports-color
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.27.0
@ -7359,6 +7942,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.25.0:
dependencies:
caniuse-lite: 1.0.30001723
electron-to-chromium: 1.5.170
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0)
buffer-builder@0.2.0: {}
buffer-crc32@0.2.13: {}
@ -7406,6 +7996,8 @@ snapshots:
camelcase@8.0.0: {}
caniuse-lite@1.0.30001723: {}
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
@ -7576,6 +8168,8 @@ snapshots:
graceful-fs: 4.2.11
xdg-basedir: 5.1.0
convert-source-map@2.0.0: {}
core-util-is@1.0.3: {}
cosmiconfig@9.0.0(typescript@5.8.2):
@ -7786,6 +8380,8 @@ snapshots:
minimatch: 9.0.1
semver: 7.6.3
electron-to-chromium@1.5.170: {}
emittery@1.1.0: {}
emoji-regex@10.4.0: {}
@ -8221,6 +8817,8 @@ snapshots:
which: 1.2.4
winreg: 0.0.12
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.3.0: {}
@ -8307,6 +8905,8 @@ snapshots:
dependencies:
ini: 4.1.1
globals@11.12.0: {}
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@ -8654,6 +9254,8 @@ snapshots:
- supports-color
- utf-8-validate
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-merge-patch@1.0.2:
@ -8672,6 +9274,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
@ -8818,6 +9422,10 @@ snapshots:
lru-cache@11.0.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@ -8937,6 +9545,8 @@ snapshots:
uuid: 8.3.2
which: 2.0.2
node-releases@2.0.19: {}
node-rsa@1.1.1:
dependencies:
asn1: 0.2.6
@ -9527,6 +10137,8 @@ snapshots:
semver@5.7.2: {}
semver@6.3.1: {}
semver@7.6.3: {}
semver@7.7.1: {}
@ -9963,7 +10575,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.2
unplugin-vue-components@28.4.1(@babel/parser@7.27.0)(vue@3.5.13(typescript@5.8.2)):
unplugin-vue-components@28.4.1(@babel/parser@7.27.5)(vue@3.5.13(typescript@5.8.2)):
dependencies:
chokidar: 3.6.0
debug: 4.4.0
@ -9975,7 +10587,7 @@ snapshots:
unplugin-utils: 0.2.4
vue: 3.5.13(typescript@5.8.2)
optionalDependencies:
'@babel/parser': 7.27.0
'@babel/parser': 7.27.5
transitivePeerDependencies:
- supports-color
@ -10001,6 +10613,12 @@ snapshots:
upath@2.0.1: {}
update-browserslist-db@1.1.3(browserslist@4.25.0):
dependencies:
browserslist: 4.25.0
escalade: 3.2.0
picocolors: 1.1.1
update-notifier@7.3.1:
dependencies:
boxen: 8.0.1
@ -10331,6 +10949,8 @@ snapshots:
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.7.1: {}
yargs-parser@21.1.1: {}

View File

@ -48,7 +48,18 @@ const emit = defineEmits<{
</template>
导入
</n-button>
<n-button type="default" ghost :round="round" :size="size" @click="emit('export')">
<n-popover v-if="$slots.exporter" trigger="hover" placement="bottom">
<template #trigger>
<n-button type="default" ghost :round="round" :size="size" @click="emit('export')">
<template #icon>
<ion-arrow-up-right-box-outline />
</template>
导出
</n-button>
</template>
<slot name="exporter" />
</n-popover>
<n-button v-else type="default" ghost :round="round" :size="size" @click="emit('export')">
<template #icon>
<ion-arrow-up-right-box-outline />
</template>

View File

@ -0,0 +1,135 @@
import { remoteHost } from '~/env';
import { useLongTask } from './useLongTask';
type AddDataFragmentsCommand = {
commandType: 'add-data-fragments';
params: Array<DataFragment>;
};
type ExportExcelCommand = {
commandType: 'export-excel';
};
type Command = AddDataFragmentsCommand | ExportExcelCommand;
type WebSocketResponse =
| { type: 'progress'; current: number; total: number }
| { type: 'result'; result: string };
class ExportExcelPipeline {
private socket: WebSocket;
constructor() {
this.socket = new WebSocket(`ws://${remoteHost}/ws/daa0b9f1-4e4a-4e7c-9269-f5f0e86ae271`);
}
private sendCommand(command: Command) {
const commandJson = JSON.stringify({ command });
this.socket!.send(commandJson);
}
public load() {
switch (this.socket.readyState) {
case WebSocket.CLOSED:
case WebSocket.CLOSING:
this.socket = new WebSocket(`ws://${remoteHost}/ws/daa0b9f1-4e4a-4e7c-9269-f5f0e86ae271`);
case WebSocket.CONNECTING:
return new Promise<ExportExcelPipeline>((resolve) => {
this.socket!.onopen = () => resolve(this);
});
case WebSocket.OPEN:
return Promise.resolve(this);
default:
return Promise.resolve(this);
}
}
public close() {
this.socket.close();
return new Promise<void>(async (resolve) => {
while (this.socket.readyState != WebSocket.CLOSED) {
await new Promise((r) => setTimeout(r, 100));
}
resolve();
});
}
public addFragments(...fragments: DataFragment[]) {
this.sendCommand({ commandType: 'add-data-fragments', params: fragments });
return this;
}
public exportExcel(progress?: (current: number, total: number) => Promise<void> | void) {
return new Promise<string>((resolve, reject) => {
this.socket.onmessage = (ev) => {
const response: WebSocketResponse = JSON.parse(ev.data);
switch (response.type) {
case 'progress':
const { current, total } = response;
progress && progress(current, total);
break;
case 'result':
this.socket!.onmessage = null;
const fileUrl = response.result;
resolve(fileUrl);
break;
default:
console.error('Unknown message type:', response);
}
};
this.socket.onclose = () => {
reject('Connection is closed');
};
this.sendCommand({ commandType: 'export-excel' });
});
}
}
export type DataFragment = {
data: Array<Record<string, any>>;
imageColumn?: string;
name?: string;
};
export const useCloudExporter = () => {
const { isRunning, startTask } = useLongTask();
const progress = reactive({ current: 0, total: 0 });
let pipeline: ExportExcelPipeline | null = null;
const stop = async () => {
if (pipeline) {
await pipeline.close();
pipeline = null;
}
};
const doExport = (fragments: DataFragment[]) =>
startTask(async () => {
progress.current = 0;
progress.total = 0;
pipeline = new ExportExcelPipeline();
await pipeline.load();
pipeline.addFragments(...fragments);
const file = await pipeline
.exportExcel((current, total) => {
progress.current = current;
progress.total = total;
})
.catch(() => null);
await pipeline.close();
if (file) {
const url = `http://${remoteHost}${file}`;
const link = document.createElement('a');
link.href = url;
link.download = `${dayjs().format('YYYY-MM-DD')}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
return file?.split('/').pop();
});
return { isRunning, progress, doExport, stop };
};

View File

@ -1,17 +1,19 @@
export function useLongTask() {
const isRunning = ref(false);
const startTask = async (task: () => Promise<void>) => {
isRunning.value = true;
try {
await task();
isRunning.value = false;
} catch (error) {
isRunning.value = false;
console.error('Task failed:', error);
throw error;
const startTask = async <T = undefined>(task: () => Promise<T>) => {
if (isRunning.value) {
throw Error('Task is still running.');
}
isRunning.value = true;
let result = undefined;
try {
result = await task();
} catch (error) {
console.error('Task failed:', error);
}
isRunning.value = false;
return result as T;
};
return {

View File

@ -12,3 +12,5 @@ export function isForbiddenUrl(url: string): boolean {
}
export const isFirefox = navigator.userAgent.includes('Firefox');
export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '127.0.0.1:8000';

View File

@ -22,9 +22,6 @@ class Worksheet {
const cols = headers.filter((h) => h.ignore?.out !== true);
for (let j = 0; j < cols.length; j++) {
const header = cols[j];
if (header.ignore?.out) {
continue;
}
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
record[header.label] = await header.formatOutputValue(value, i);
@ -196,6 +193,30 @@ export type ImportBaseOptions = {
headers?: Header[];
};
export function castRecordsByHeaders<T = Record<string, unknown>>(
jsonData: Record<string, unknown>[],
headers: Omit<Header, 'parseImportValue'>[],
): Promise<T[]> {
return Promise.all(
jsonData.map(async (item, i) => {
const record: Record<string, unknown> = {};
const cols = headers.filter((h) => h.ignore?.out !== true);
for (let j = 0; j < cols.length; j++) {
const header = cols[j];
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
record[header.label] = await header.formatOutputValue(value, i);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
record[header.label] = value;
} else {
record[header.label] = JSON.stringify(value);
}
}
return record as T;
}),
);
}
/**
* XLSX
* @param data

View File

@ -6,7 +6,7 @@ import {
AmazonDetailPageInjector,
AmazonReviewPageInjector,
AmazonSearchPageInjector,
} from '../web-injectors';
} from '~/logic/web-injectors/amazon';
import { isForbiddenUrl } from '~/env';
/**

View File

@ -2,7 +2,7 @@ import Emittery from 'emittery';
import { HomedepotEvents, HomedepotWorker } from './types';
import { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler';
import { HomedepotDetailPageInjector } from '../web-injectors';
import { HomedepotDetailPageInjector } from '~/logic/web-injectors/homedepot';
class HomedepotWorkerImpl implements HomedepotWorker {
private static _instance: HomedepotWorker | null = null;

View File

@ -39,18 +39,6 @@ type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
Partial<AmazonSearchItem> &
Partial<AmazonDetailItem> & { hasDetail: boolean };
type HomedepotDetailItem = {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
innerText: string;
reviewCount?: number;
mainImageUrl: string;
};
interface AmazonPageWorkerEvents {
/**
* The event is fired when worker collected links to items on the Amazon search page.
@ -86,17 +74,6 @@ interface AmazonPageWorkerEvents {
['error']: { message: string; url?: string };
}
interface HomedepotEvents {
/**
* The event is fired when detail items collect
*/
['detail-item-collected']: { item: HomedepotDetailItem };
/**
* The event is fired when error occurs.
*/
['error']: { message: string; url?: string };
}
interface AmazonPageWorker {
/**
* The channel for communication with the Amazon page worker.
@ -137,6 +114,30 @@ interface AmazonPageWorker {
stop(): Promise<void>;
}
type HomedepotDetailItem = {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
innerText: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};
interface HomedepotEvents {
/**
* The event is fired when detail items collect
*/
['detail-item-collected']: { item: HomedepotDetailItem };
/**
* The event is fired when error occurs.
*/
['error']: { message: string; url?: string };
}
interface HomedepotWorker {
/**
* The channel for communication with the Homedepot page worker.
@ -156,3 +157,47 @@ interface HomedepotWorker {
*/
stop(): Promise<void>;
}
type LowesDetailItem = {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
innerText: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};
interface LowesEvents {
/**
* The event is fired when detail items collect
*/
['detail-item-collected']: { item: LowesDetailItem };
/**
* The event is fired when error occurs.
*/
['error']: { message: string; url?: string };
}
interface LowesWorker {
/**
* The channel for communication with the Lowes page worker.
*/
readonly channel: Emittery<LowesEvents>;
/**
* Browsing goods detail page and collect target information
*/
runDetailPageTask(
urls: string[],
progress?: (remains: string[]) => Promise<void> | void,
): Promise<void>;
/**
* Stop the worker.
*/
stop(): Promise<void>;
}

View File

@ -1,24 +1,5 @@
import { exec } from './execute-script';
import type { Tabs } from 'webextension-polyfill';
import type { AmazonReview, AmazonSearchItem, HomedepotDetailItem } from './page-worker/types';
class BaseInjector {
readonly _tab: Tabs.Tab;
readonly _timeout: number;
constructor(tab: Tabs.Tab, timeout: number = 30000) {
this._tab = tab;
this._timeout = timeout;
}
run<T, P extends Record<string, unknown>>(
func: (payload: P) => Promise<T>,
payload?: P,
): Promise<T> {
return exec(this._tab.id!, func, payload as P, { timeout: this._timeout });
}
}
import { BaseInjector } from './base';
import { AmazonReview, AmazonSearchItem } from '../page-worker/types';
export class AmazonSearchPageInjector extends BaseInjector {
public waitForPageLoaded() {
@ -167,7 +148,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
const targetNode = document.querySelector(
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li), .av-page-desktop',
);
const exceptionalNodeSelectors = ['music-detail-header', '.avu-retail-page'];
const exceptionalNodeSelectors = ['.music-detail-header', '.avu-retail-page'];
for (const selector of exceptionalNodeSelectors) {
if (document.querySelector(selector)) {
return false;
@ -444,79 +425,3 @@ export class AmazonReviewPageInjector extends BaseInjector {
);
}
}
export class HomedepotDetailPageInjector extends BaseInjector {
constructor(tab: Tabs.Tab) {
super(tab, 60000);
}
public waitForPageLoad() {
return this.run(async () => {
let timeout = false;
setTimeout(() => (timeout = true), 15000);
const isLoaded = () => {
const reviewPlaceholderEl = document.querySelector(
`#product-section-rr div[role='button'][aria-expanded='true']`,
);
return reviewPlaceholderEl && (document.readyState == 'complete' || timeout);
};
while (true) {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));
document
.querySelector<HTMLElement>(
`#product-section-rr div[role='button'][aria-expanded='false'], #product-section-overview div[role='button'][aria-expanded='false']`,
)
?.click();
const { scrollHeight, scrollTop } = document.documentElement;
scrollHeight - scrollTop > 100
? window.scrollBy({ top: 100, behavior: 'smooth' })
: document
.querySelector('[data-component^="product-details:ProductDetailsTitle"]')
?.scrollIntoView({ behavior: 'smooth' });
if (isLoaded()) {
break;
}
}
});
}
public getInfo() {
return this.run(async () => {
const link = document.location.toString();
const brandName = document.querySelector<HTMLDivElement>(
`[data-component^="product-details:ProductDetailsBrandCollection"]`,
)?.innerText;
const title = document.querySelector<HTMLDivElement>(
`[data-component^="product-details:ProductDetailsTitle"]`,
)!.innerText;
const price = document
.querySelector<HTMLDivElement>(`#standard-price`)!
.innerText.replaceAll('\n', '');
const rateEl = document.querySelector<HTMLDivElement>(
`[data-component^="ratings-and-reviews"] .sui-mr-1`,
);
const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined;
const reviewCount = rateEl
? Number(
/\d+/.exec(
document.querySelector<HTMLDivElement>(
`[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`,
)!.innerText,
)![0],
)
: undefined;
const mainImageUrl = document.querySelector<HTMLImageElement>(
`.mediagallery__mainimage img`,
)!.src;
return {
link,
brandName,
title,
price,
rate,
reviewCount,
mainImageUrl,
} as Omit<HomedepotDetailItem, 'OSMID'>;
});
}
}

View File

@ -0,0 +1,20 @@
import { Tabs } from 'webextension-polyfill';
import { exec } from '~/logic/execute-script';
export class BaseInjector {
readonly _tab: Tabs.Tab;
readonly _timeout: number;
constructor(tab: Tabs.Tab, timeout: number = 30000) {
this._tab = tab;
this._timeout = timeout;
}
run<T, P extends Record<string, unknown>>(
func: (payload: P) => Promise<T>,
payload?: P,
): Promise<T> {
return exec(this._tab.id!, func, payload as P, { timeout: this._timeout });
}
}

View File

@ -0,0 +1,87 @@
import { Tabs } from 'webextension-polyfill';
import { BaseInjector } from './base';
import { HomedepotDetailItem } from '../page-worker/types';
export class HomedepotDetailPageInjector extends BaseInjector {
constructor(tab: Tabs.Tab) {
super(tab, 60000);
}
public waitForPageLoad() {
return this.run(async () => {
let timeout = false;
setTimeout(() => (timeout = true), 15000);
const isLoaded = () => {
const reviewPlaceholderEl = document.querySelector(
`#product-section-rr div[role='button'][aria-expanded='true']`,
);
return reviewPlaceholderEl && (document.readyState == 'complete' || timeout);
};
while (true) {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));
document
.querySelector<HTMLElement>(
`#product-section-rr div[role='button'][aria-expanded='false'], #product-section-overview div[role='button'][aria-expanded='false']`,
)
?.click();
const { scrollHeight, scrollTop } = document.documentElement;
scrollHeight - scrollTop > 100
? window.scrollBy({ top: 100, behavior: 'smooth' })
: document
.querySelector('[data-component^="product-details:ProductDetailsTitle"]')
?.scrollIntoView({ behavior: 'smooth' });
if (isLoaded()) {
break;
}
}
});
}
public getInfo() {
return this.run(async () => {
const link = document.location.toString();
const brandName = document.querySelector<HTMLDivElement>(
`[data-component^="product-details:ProductDetailsBrandCollection"]`,
)?.innerText;
const title = document.querySelector<HTMLDivElement>(
`[data-component^="product-details:ProductDetailsTitle"]`,
)!.innerText;
const price = document
.querySelector<HTMLDivElement>(`#standard-price`)!
.innerText.replaceAll('\n', '');
const rateEl = document.querySelector<HTMLDivElement>(
`[data-component^="ratings-and-reviews"] .sui-mr-1`,
);
const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined;
const reviewCount = rateEl
? Number(
/\d+/.exec(
document.querySelector<HTMLDivElement>(
`[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`,
)!.innerText,
)![0],
)
: undefined;
const mainImageUrl = document.querySelector<HTMLImageElement>(
`.mediagallery__mainimage img`,
)!.src;
const modelInfoEl = document.evaluate(
`//*[@id="product-section-product-overview"]//*[@class="product-info-bar"]//*[starts-with(text(), "Model #")]`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
const [modelInfo] = /(?<=#\s).+/.exec(modelInfoEl?.innerText || '') || [];
return {
link,
brandName,
title,
price,
rate,
reviewCount,
mainImageUrl,
modelInfo,
} as Omit<HomedepotDetailItem, 'OSMID'>;
});
}
}

View File

@ -0,0 +1,85 @@
import { Tabs } from 'webextension-polyfill';
import { BaseInjector } from './base';
export class LowesDetailPageInjector extends BaseInjector {
constructor(tab: Tabs.Tab) {
super(tab, 60000);
}
public waitForPageLoad() {
return this.run(async () => {
while (true) {
if (document.readyState === 'complete') {
break;
}
}
});
}
public getBaseInfo() {
return this.run(async () => {
// 获取Item #
const itemNumberEl = document.evaluate(
`//p[starts-with(text(), "Item #")]`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
const itemSeries = itemNumberEl?.innerText.replace('Item #', '').trim();
// 获取Model #
const modelNumberEl = document.evaluate(
`//p[starts-with(text(), "Model #")]`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
const modelSeries = modelNumberEl?.innerText.replace('Model #', '').trim();
// 获取品牌名称
const brandName = (
document.evaluate(
`//h1[contains(@class, "product-brand-description")]/parent::*/parent::*/following-sibling::*[1]//a`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement
).innerText;
// 获取标题
const title = document.querySelector<HTMLDivElement>(
`h1.product-brand-description`,
)?.innerText;
// 获取价格
const price = document
.querySelector<HTMLDivElement>(`.screen-reader`)
?.innerText.replaceAll('\n', '');
// 获取评分
const rate = document.querySelector<HTMLDivElement>(`.avgrating`)?.innerText;
// 获取评价数量
const reviewCount = Number(
document.querySelector<HTMLDivElement>(`[data-testid="rating-trigger"] > div > div > span`)
?.innerText || '0',
);
// 获取图片URL
const mainImageUrl = document.querySelector<HTMLImageElement>(
`#mfe-gallery .productImage.tile-img`,
)?.src;
return {
brandName,
title,
price,
rate,
reviewCount,
mainImageUrl,
itemSeries,
modelSeries,
};
});
}
}

View File

@ -15,12 +15,14 @@ const theme: GlobalThemeOverrides = {
<template>
<!-- Naive UI Wrapper-->
<n-config-provider :theme-overrides="theme">
<n-message-provider>
<n-dialog-provider>
<n-modal-provider>
<options />
</n-modal-provider>
</n-dialog-provider>
</n-message-provider>
<n-loading-bar-provider>
<n-message-provider>
<n-dialog-provider>
<n-modal-provider>
<options />
</n-modal-provider>
</n-dialog-provider>
</n-message-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@ -3,23 +3,31 @@ import { RouterView } from 'vue-router';
</script>
<template>
<main>
<header>
<span></span>
<h1 class="header-title">采集结果</h1>
<!-- <result-table class="result-table" /> -->
<span></span>
</header>
<main>
<router-view />
</main>
</template>
<style scoped lang="scss">
header {
display: flex;
flex-direction: row;
justify-content: space-between;
.header-title {
cursor: default;
}
}
main {
display: flex;
flex-direction: column;
align-items: center;
.header-title {
cursor: default;
}
.result-table {
height: 90vh;
width: 95vw;

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
<script setup lang="tsx">
import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
import { createWorkbook, Header, importFromXLSX } from '~/logic/data-io';
import type { AmazonDetailItem, AmazonItem, AmazonReview } from '~/logic/page-worker/types';
import { useCloudExporter } from '~/composables/useCloudExporter';
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/data-io';
import type { AmazonItem, AmazonReview } from '~/logic/page-worker/types';
import { allItems, reviewItems } from '~/logic/storage';
import DetailDescription from '~/components/DetailDescription.vue';
import ReviewPreview from '~/components/ReviewPreview.vue';
const message = useMessage();
const modal = useModal();
const cloudExporter = useCloudExporter();
const page = reactive({ current: 1, size: 10 });
@ -49,7 +49,7 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
type: 'expand',
expandable: (row) => row.hasDetail,
renderExpand(row) {
return h(DetailDescription, { model: row as AmazonDetailItem }, () => '');
return <detail-description model={row} />;
},
},
{
@ -76,7 +76,7 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
title: '标题',
key: 'title',
render(row) {
return h('div', { style: {} }, `${row.title}`);
return <div>{row.title}</div>;
},
},
{
@ -99,49 +99,40 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
key: 'actions',
minWidth: 100,
render(row) {
return h(NSpace, {}, () =>
[
{
text: '评论',
disabled: !reviewItems.value.has(row.asin),
onClick: () => {
const asin = row.asin;
modal.create({
title: `${asin}评论`,
preset: 'card',
style: {
width: '80vw',
height: '85vh',
},
content: () =>
h(ReviewPreview, {
asin,
}),
});
},
},
{
text: '链接',
onClick: () => {
browser.tabs.create({
active: true,
url: row.link,
});
},
},
].map(({ text, onClick, disabled }) =>
h(
NButton,
return (
<n-space>
{[
{
type: 'primary',
text: true,
size: 'small',
disabled: disabled,
onClick: onClick,
text: '评论',
disabled: !reviewItems.value.has(row.asin),
onClick: () => {
const asin = row.asin;
modal.create({
title: `${asin}评论`,
preset: 'card',
style: {
width: '80vw',
height: '85vh',
},
content: () => <review-preview asin={asin} />,
});
},
},
() => text,
),
),
{
text: '链接',
onClick: () => {
browser.tabs.create({
active: true,
url: row.link,
});
},
},
].map(({ text, onClick, disabled }) => (
<n-button type="primary" text size="small" disabled={disabled} onClick={onClick}>
{text}
</n-button>
))}
</n-space>
);
},
},
@ -231,7 +222,7 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
return data;
};
const handleExport = async () => {
const handleLocalExport = async () => {
const itemHeaders = getItemHeaders();
const items = toRaw(itemView.value).origin;
const asins = new Set(items.map((e) => e.asin));
@ -249,9 +240,34 @@ const handleExport = async () => {
const sheet2 = wb.addSheet('reviews');
await sheet2.readJson(reviews, { headers: reviewHeaders });
await wb.exportFile(`Items ${dayjs().format('YYYY-MM-DD')}.xlsx`);
message.info('导出完成');
};
const handleCloudExport = async () => {
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
const itemHeaders = getItemHeaders();
const items = toRaw(itemView.value).origin;
const asins = new Set(items.map((e) => e.asin));
const reviews = toRaw(reviewItems.value)
.entries()
.filter(([asin]) => asins.has(asin))
.reduce<(AmazonReview & { asin: string })[]>((a, [asin, reviews]) => {
a.push(...reviews.map((r) => ({ asin, ...r })));
return a;
}, []);
const mappedData1 = await castRecordsByHeaders(items, itemHeaders);
const mappedData2 = await castRecordsByHeaders(reviews, reviewHeaders);
const fragments = [
{ data: mappedData1, imageColumn: '商品图片链接', name: 'items' },
{ data: mappedData2, imageColumn: '图片链接', name: 'reviews' },
];
const filename = await cloudExporter.doExport(fragments);
filename && message.info(`导出完成`);
};
const handleImport = async (file: File) => {
const itemHeaders = getItemHeaders();
const wb = await importFromXLSX(file, { asWorkBook: true });
@ -306,13 +322,46 @@ const handleClearData = async () => {
clearable
style="min-width: 230px"
/>
<control-strip
round
size="small"
@clear="handleClearData"
@export="handleExport"
@import="handleImport"
>
<control-strip round size="small" @clear="handleClearData" @import="handleImport">
<template #exporter>
<ul v-if="!cloudExporter.isRunning.value" class="exporter-menu">
<li @click="handleLocalExport">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">
<n-icon><lucide-sheet /></n-icon>
<span>本地导出</span>
</div>
</template>
不包含图片
</n-tooltip>
</li>
<li @click="handleCloudExport">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">
<n-icon><ic-outline-cloud /></n-icon>
<span>云端导出</span>
</div>
</template>
包含图片
</n-tooltip>
</li>
</ul>
<div v-else class="expoter-progress-panel">
<n-progress
type="circle"
:percentage="
(cloudExporter.progress.current * 100) / cloudExporter.progress.total
"
>
<span>
{{ cloudExporter.progress.current }}/{{ cloudExporter.progress.total }}
</span>
</n-progress>
<n-button @click="cloudExporter.stop()">停止</n-button>
</div>
</template>
<template #filter>
<div class="filter-section">
<div class="filter-title">筛选器</div>
@ -383,6 +432,49 @@ const handleClearData = async () => {
flex-direction: row-reverse;
}
.exporter-menu {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
padding: 0;
margin: 0;
list-style: none;
font-size: 15px;
li {
padding: 5px 10px;
cursor: pointer;
transition: background 0.15s;
color: #222;
user-select: none;
border-radius: 6px;
&:hover {
background: #f0f6fa;
color: #007bff;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
}
}
.expoter-progress-panel {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-size: 18px;
padding: 10px;
gap: 15px;
cursor: wait;
}
.filter-section {
min-width: 250px;

View File

@ -35,12 +35,14 @@ watch(currentUrl, (newVal) => {
<template>
<!-- Naive UI Wrapper-->
<n-config-provider :theme-overrides="theme">
<n-message-provider>
<n-dialog-provider>
<n-modal-provider>
<router-view />
</n-modal-provider>
</n-dialog-provider>
</n-message-provider>
<n-loading-bar-provider>
<n-message-provider>
<n-dialog-provider>
<n-modal-provider>
<router-view />
</n-modal-provider>
</n-dialog-provider>
</n-message-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@ -29,7 +29,7 @@ const running = ref(false);
<template>
<div class="side-panel">
<div class="header-menu">
<header class="header-menu">
<n-tabs
:tab-style="{ cursor: running ? 'not-allowed' : undefined }"
placement="top"
@ -46,12 +46,12 @@ const running = ref(false);
>
<n-tab v-for="tab in tabs" :name="tab.name" />
</n-tabs>
</div>
<div class="main-content">
</header>
<main class="main-content">
<keep-alive>
<Component :is="currentComponent" @start="running = true" @stop="running = false" />
</keep-alive>
</div>
</main>
</div>
</template>

View File

@ -3,6 +3,7 @@
"incremental": false,
"target": "es2016",
"jsx": "preserve",
"jsxImportSource": "vue",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",

View File

@ -4,6 +4,7 @@ import { dirname, relative } from 'node:path';
import type { UserConfig } from 'vite';
import { defineConfig } from 'vite';
import Vue from '@vitejs/plugin-vue';
import VueJsx from '@vitejs/plugin-vue-jsx';
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';
import Components from 'unplugin-vue-components/vite';
@ -26,6 +27,7 @@ export const sharedConfig: UserConfig = {
},
plugins: [
Vue(),
VueJsx(),
AutoImport({
imports: [
'vue',
@ -100,11 +102,11 @@ export default defineConfig(({ command }) => ({
sidepanel: r('src/sidepanel/index.html'),
options: r('src/options/index.html'),
},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('naive-ui')) return 'vendor-naive-ui';
if (id.includes('exceljs')) return 'vendor-exceljs';
else if (id.includes('naive-ui')) return 'vendor-naive-ui';
else if (id.includes('vue')) return 'vendor-vue';
return 'vendor';
}