This commit is contained in:
zhu
2026-05-13 16:59:26 +08:00
parent cb5a13d352
commit 2d1397c277
18 changed files with 1281 additions and 285 deletions

View File

@@ -22,7 +22,7 @@ export default defineManifest({
},
],
host_permissions: ['https://*/*', 'http://*/*'],
permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows'],
permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows', 'alarms'],
background: {
service_worker: 'src/background/index.ts',
type: 'module',

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.0",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.6"

211
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
axios:
specifier: ^1.16.0
version: 1.16.0
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3))
@@ -152,36 +155,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -240,36 +249,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -342,24 +357,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
@@ -510,6 +529,12 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.16.0:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -520,6 +545,10 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -528,6 +557,10 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -560,6 +593,10 @@ packages:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -577,6 +614,10 @@ packages:
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
enhanced-resolve@5.21.0:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
@@ -589,9 +630,25 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@0.10.5:
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -618,6 +675,19 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -627,13 +697,40 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.3:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@@ -712,24 +809,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -758,6 +859,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -766,6 +871,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -834,6 +947,10 @@ packages:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1472,6 +1589,16 @@ snapshots:
'@babel/parser': 7.29.2
ast-kit: 2.2.0
asynckit@0.4.0: {}
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
birpc@2.9.0: {}
boolbase@1.0.0: {}
@@ -1480,6 +1607,11 @@ snapshots:
dependencies:
fill-range: 7.1.1
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -1488,6 +1620,10 @@ snapshots:
dependencies:
readdirp: 5.0.0
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
confbox@0.1.8: {}
confbox@0.2.4: {}
@@ -1514,6 +1650,8 @@ snapshots:
dependencies:
ms: 2.1.3
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {}
dom-serializer@2.0.0:
@@ -1534,6 +1672,12 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
enhanced-resolve@5.21.0:
dependencies:
graceful-fs: 4.2.11
@@ -1543,8 +1687,23 @@ snapshots:
entities@7.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@0.10.5: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.3
estree-walker@2.0.2: {}
exsolve@1.0.8: {}
@@ -1569,6 +1728,16 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.3
mime-types: 2.1.35
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -1578,12 +1747,44 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.3
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.3:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hookable@5.5.3: {}
@@ -1675,6 +1876,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -1682,6 +1885,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mitt@3.0.1: {}
mlly@1.8.2:
@@ -1748,6 +1957,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
proxy-from-env@2.1.0: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}

24
src/api/me.ts Normal file
View File

@@ -0,0 +1,24 @@
import request from "@/shared/request";
/**
* 获取扩展侧可用的配额/配置(扩展 token 鉴权)
*
* 中文备注:
* - API 文档GET `/api/me/scan-quota`
* - 按你的要求:返回值/参数全部用 any不定义复杂类型
*/
export async function getScanQuotaApi(): Promise<any> {
return await request.get("/me/scan-quota");
}
/**
* 扩展心跳(用于后端记录扩展活跃/跳过原因)
*
* 中文备注:
* - API 文档POST `/api/extension/heartbeat`
* - 这里不强依赖心跳成功,失败不应影响爬取主流程
*/
export async function extensionHeartbeatApi(body: any): Promise<any> {
return await request.post("/extension/heartbeat", body);
}

12
src/api/scan.ts Normal file
View File

@@ -0,0 +1,12 @@
import request from "@/shared/request";
/**
* 上传扫描数据(扩展侧直传后端)。
* 中文备注:接口定义来自 `StoreAI-New/BACKEND_API_DOCUMENTATION.md` 的 POST `/api/ingest/scan`。
*
* 注意:按你的要求:返回值、参数都用 any不定义复杂类型。
*/
export async function ingestScanApi(body: any): Promise<any> {
return await request.post("/ingest/scan", body);
}

View File

@@ -1,58 +1,68 @@
import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from './service/externalBridge';
import {MessageAction} from "@/shared/message";
import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from "./service/externalBridge";
import type {MessageAction} from "@/shared/message";
import {
cancelCrawl,
dismissCrawl,
pauseCrawlOnTabRemoved,
pauseCrawlOnWindowRemoved,
resumeCrawl,
startCrawl
startCrawl,
} from "./task/crawlTask";
import {getCrawlTaskState, updateCrawlTaskState} from "./task/taskState";
import {initAuthState} from "@/shared/store";
import {initAutoScanScheduler, rescheduleAutoScanAlarms} from "@/background/service/autoScanScheduler";
// 中文备注MV3 service worker 可能随时被系统回收;启动时先从 storage 恢复 authState 到内存,供 axios 同步读取。
void initAuthState();
// 中文备注:初始化定时爬取调度器(注册 alarms 监听 + 启动时重建 alarms
initAutoScanScheduler();
chrome.runtime.onInstalled.addListener(() => {
// 中文备注:安装/更新后尝试重建一次定时任务(如果已配对过,会从 storage 取到配置)。
void rescheduleAutoScanAlarms("onInstalled");
});
chrome.runtime.onStartup.addListener(() => {
// 中文备注:浏览器启动时重建定时任务,避免 service worker 回收导致 alarms 丢失。
void rescheduleAutoScanAlarms("onStartup");
});
/**
* 接popup的指令
* 接popup/content 的消息
*/
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// 1. 统一提取 action 和 payload
const action = message.action as MessageAction;
const payload = message.payload;
// 2. 使用一个异步立即执行函数来处理逻辑
(async () => {
try {
let resultData: any = null;
// 3. 根据 action 分发任务
switch (action) {
case "START_CRAWL":
resultData = await startCrawl(payload.platformId);
// 中文备注:手动触发
resultData = await startCrawl(payload.platformId, "manual");
// 中文备注startCrawl 可能返回 `{ error: ... }`,这里转成 ok:false 给 popup 处理
if (resultData && typeof resultData === "object" && "error" in resultData && !("id" in resultData)) {
throw new Error(String((resultData as any).error || "start_failed"));
}
break;
case "GET_CRAWL_STATE":
resultData = await getCrawlTaskState();
break;
case "GET_CRAWL_STATE_FOR_TAB": {
// 中文备注:只允许“爬取窗口 tab”拿到状态避免其它页面误显示悬浮面板
// 中文备注:只允许“爬取窗口 tab”拿到状态避免其它页面误显示 overlay
const state = await getCrawlTaskState();
const senderTabId = sender.tab?.id;
resultData = state && senderTabId && state.tabId === senderTabId ? state : null;
break;
}
case "CANCEL_CRAWL":
await cancelCrawl()
await cancelCrawl();
break;
case "CANCEL_AUTOCLOSE": {
// 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口,这里仅做状态字段兼容
// 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口。
const state = await getCrawlTaskState();
if (state) {
await updateCrawlTaskState(state.id, (s) => ({...s, autocloseAt: null}));
@@ -62,23 +72,20 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
break;
}
case "RESUME_CRAWL":
resultData = await resumeCrawl();
break;
case "DISMISS_CRAWL":
await dismissCrawl();
break;
default:
throw new Error(`未知的后台指令: ${action}`);
throw new Error(`未知的后台指令 ${action}`);
}
sendResponse({ok: true, data: resultData});
} catch (error: any) {
console.error(`[Background] Action ${action} failed:`, error);
sendResponse({ok: false, error: error.message || 'Unknown error'});
sendResponse({ok: false, error: error?.message || "Unknown error"});
}
})();
@@ -86,44 +93,46 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
/**
* 监听窗口关闭:
* 用户手动关掉爬窗口时,自动触发任务清理逻辑(取消任务、停掉后台循环)
* 监听窗口/Tab 关闭:
* - 用户手动关掉爬窗口时,不直接取消任务,而是自动暂停,等待用户点击继续
*/
chrome.windows.onRemoved.addListener((windowId) => {
// 中文备注:用户手动关掉爬取窗口时,不要直接取消任务;要切换为暂停,等用户在 popup 点“继续”恢复。
void pauseCrawlOnWindowRemoved(windowId);
});
chrome.tabs.onRemoved.addListener((tabId) => {
// 中文备注:兜底处理:有些情况下只会触发 tab 移除事件,这里同样按“窗口被关闭”暂停。
void pauseCrawlOnTabRemoved(tabId);
});
/**
* 接收外部网页消息
* 允许在 manifest.json 中授权的官网域名(如 your-app.com直接调起插件功能。
* 接收外部网页消息onMessageExternal
*/
chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => {
void handleExternalMessage(message).then(sendResponse).catch((error: unknown) => {
sendResponse({
ok: false,
error: error instanceof Error ? error.message : String(error),
void handleExternalMessage(message)
.then(sendResponse)
.catch((error: unknown) => {
sendResponse({
ok: false,
error: error instanceof Error ? error.message : String(error),
});
});
});
return true; // 保持异步响应通道开启
return true; // 中文备注:保持异步响应通道开启
});
/**
* 处理外部长连接:
* 用于官网页面与插件后台建立持久通信,实现实时的数据流同步。
* 外部网页长连接onConnectExternal
*/
chrome.runtime.onConnectExternal.addListener(handleExternalConnect);
/**
* 监听存储变化:
* 只要插件的本地数据storage发生改动就立即广播给所有 UIPopup/网页),实现进度条同步。
* 监听 storage 变化并广播给 popup/overlay/外部网页
*/
chrome.storage.onChanged.addListener((changes, areaName) => {
broadcastCrawlStorageChange(changes, areaName);
// 中文备注:配对成功/退出登录会写入 auth_state这里同步重建定时爬取 alarms
if (areaName === "local" && changes["auth_state"]) {
void rescheduleAutoScanAlarms("auth_state_changed");
}
});

View File

@@ -0,0 +1,231 @@
import {platformConfigs} from "@/config/platforms";
import {getAuthState} from "@/shared/store";
import {getCrawlTaskState} from "@/background/task/taskState";
import {startCrawl} from "@/background/task/crawlTask";
import {extensionHeartbeatApi, getScanQuotaApi} from "@/api/me";
const ALARM_MORNING = "DIANSHAN_AUTO_SCAN_MORNING";
const ALARM_EVENING = "DIANSHAN_AUTO_SCAN_EVENING";
let schedulerInited = false;
/**
* 初始化定时爬取调度器(只需要调用一次)
*
* 中文备注:
* - MV3 service worker 可能被回收,模块会被重新执行;用 schedulerInited 防止重复注册监听
*/
export function initAutoScanScheduler() {
if (schedulerInited) return;
schedulerInited = true;
chrome.alarms.onAlarm.addListener((alarm) => {
void handleAlarm(alarm.name);
});
// 中文备注:启动时也做一次重建,避免用户只开扩展不打开浏览器启动事件时遗漏
void rescheduleAutoScanAlarms("init");
}
/**
* 重建定时爬取 alarms
*
* 中文备注:
* - 需求:需要“调用接口拿到配置好的定时时间”,这里会先调用 `/api/me/scan-quota` 做一次拉取(并允许后端在该接口里下发定时字段)
* - 如果接口没有下发定时字段,则回退到配对时存下来的 `authState.morningBriefHour/eveningRecapHour/timezone`
*/
export async function rescheduleAutoScanAlarms(reason: string) {
const auth = getAuthState();
if (!auth?.token) {
// 中文备注:未配对/未登录时,直接清掉所有自动爬取 alarm
await chrome.alarms.clear(ALARM_MORNING);
await chrome.alarms.clear(ALARM_EVENING);
return;
}
// 中文备注:按需求“调用接口拿配置”,同时也能借机触发 ext_outdated/unauthorized 等错误暴露
let quota: any = null;
try {
quota = await getScanQuotaApi();
} catch {
quota = null;
}
// 中文备注:配置字段优先级:接口下发 > authState 里缓存
const timezone = quota?.timezone || auth?.timezone || "Asia/Shanghai";
const morningHour = normaliseHour(quota?.morningBriefHour ?? quota?.morning_brief_hour ?? auth?.morningBriefHour);
const eveningHour = normaliseHour(quota?.eveningRecapHour ?? quota?.evening_recap_hour ?? auth?.eveningRecapHour);
// 中文备注:分别创建两个一次性 alarm触发后会在 handleAlarm 里再次续约下一次
if (morningHour === null) {
await chrome.alarms.clear(ALARM_MORNING);
} else {
const when = getNextRunAtMs(timezone, morningHour);
chrome.alarms.create(ALARM_MORNING, {when});
}
if (eveningHour === null) {
await chrome.alarms.clear(ALARM_EVENING);
} else {
const when = getNextRunAtMs(timezone, eveningHour);
chrome.alarms.create(ALARM_EVENING, {when});
}
// 中文备注:心跳可选,用于后端观测扩展侧是否成功配置定时
void extensionHeartbeatApi({
skip_reason: null,
ext_version: chrome.runtime.getManifest().version,
tick_at: new Date().toISOString(),
reason,
}).catch(() => undefined);
}
async function handleAlarm(name: string) {
if (name !== ALARM_MORNING && name !== ALARM_EVENING) return;
const auth = getAuthState();
if (!auth?.token) {
// 中文备注:没有 token 直接忽略,并清理 alarm
await chrome.alarms.clear(name);
return;
}
// 中文备注如果当前已有任务running/paused本次自动扫描跳过避免并发打开多个窗口
const state = await getCrawlTaskState();
if (state && ["running", "paused"].includes(state.status)) {
void extensionHeartbeatApi({
skip_reason: "task_in_progress",
ext_version: chrome.runtime.getManifest().version,
tick_at: new Date().toISOString(),
}).catch(() => undefined);
await rescheduleAutoScanAlarms("skip_task_in_progress");
return;
}
// 中文备注scheduled 扫描默认使用第一个平台(当前仅 Shopee
const platformId = platformConfigs[0]?.id;
if (!platformId) {
void extensionHeartbeatApi({
skip_reason: "no_platform_config",
ext_version: chrome.runtime.getManifest().version,
tick_at: new Date().toISOString(),
}).catch(() => undefined);
await rescheduleAutoScanAlarms("no_platform_config");
return;
}
// 中文备注:触发 scheduled 扫描
const started: any = await startCrawl(platformId, "scheduled");
if (started && typeof started === "object" && "error" in started && !("id" in started)) {
void extensionHeartbeatApi({
skip_reason: String((started as any).error || "start_failed"),
ext_version: chrome.runtime.getManifest().version,
tick_at: new Date().toISOString(),
}).catch(() => undefined);
await rescheduleAutoScanAlarms("start_failed");
return;
}
// 中文备注:触发后立刻续约下一次(一次性 alarm
await rescheduleAutoScanAlarms("after_alarm_fired");
}
function normaliseHour(v: any): number | null {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
if (n < 0 || n > 23) return null;
return Math.floor(n);
}
/**
* 计算“品牌时区”下,下一个整点触发时间(返回 UTC ms
*
* 中文备注:
* - 这里不用第三方库,靠 Intl + 时区偏移换算
* - 采用“一次性 alarm + 触发后续约”的方式,能更好适配 DST/时区变化
*/
function getNextRunAtMs(timezone: string, targetHour: number) {
const now = new Date();
const nowParts = getZonedParts(now, timezone);
const nowTotalSec = nowParts.hour * 3600 + nowParts.minute * 60 + nowParts.second;
const targetTotalSec = targetHour * 3600;
// 中文备注:如果当前时区时间已经过了目标小时,则取下一天
const addDays = nowTotalSec >= targetTotalSec ? 1 : 0;
const next = addDays === 0
? {year: nowParts.year, month: nowParts.month, day: nowParts.day}
: addDaysToYmd(nowParts.year, nowParts.month, nowParts.day, addDays);
return zonedTimeToUtcMs(timezone, next.year, next.month, next.day, targetHour, 0, 0);
}
function getZonedParts(date: Date, timeZone: string) {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = dtf.formatToParts(date);
const out: any = {};
for (const p of parts) {
if (p.type === "year" || p.type === "month" || p.type === "day" || p.type === "hour" || p.type === "minute" || p.type === "second") {
out[p.type] = Number(p.value);
}
}
return {
year: out.year,
month: out.month,
day: out.day,
hour: out.hour,
minute: out.minute,
second: out.second,
};
}
function addDaysToYmd(year: number, month: number, day: number, addDays: number) {
const d = new Date(Date.UTC(year, month - 1, day + addDays, 0, 0, 0));
return {year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate()};
}
function zonedTimeToUtcMs(timeZone: string, year: number, month: number, day: number, hour: number, minute: number, second: number) {
// 中文备注:先用 UTC 作为“猜测”,再根据该时刻时区偏移修正到正确 UTC
const guessUtcMs = Date.UTC(year, month - 1, day, hour, minute, second);
let offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(guessUtcMs));
let adjusted = guessUtcMs - offsetMin * 60 * 1000;
// 中文备注DST 场景下,第一次修正后偏移可能变化,再算一次确保收敛
offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(adjusted));
adjusted = guessUtcMs - offsetMin * 60 * 1000;
return adjusted;
}
function getTimeZoneOffsetMinutes(timeZone: string, date: Date) {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "shortOffset",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = dtf.formatToParts(date);
const tzName = parts.find((p) => p.type === "timeZoneName")?.value || "GMT+0";
// 兼容GMT+8 / GMT+08:00 / GMT-5
const m = tzName.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!m) return 0;
const sign = m[1] === "-" ? -1 : 1;
const hh = Number(m[2] || 0);
const mm = Number(m[3] || 0);
return sign * (hh * 60 + mm);
}

View File

@@ -2,6 +2,7 @@ import {platformConfigs} from '@/config/platforms';
import type {CrawlTaskState} from '@/types';
import {getCrawlTaskState} from "@/background/task/taskState";
import {cancelCrawl, startCrawl} from "@/background/task/crawlTask";
import {setAuthState} from "@/shared/store";
/** 存储任务状态的 Key需与存储层保持一致 */
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
@@ -11,6 +12,8 @@ const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL';
/** 定义外部(网页侧)可以发起的动作类型 */
type ExternalAction =
| 'DIANSHAN_PING' // 探测插件是否安装/活跃
| 'DIANSHAN_AUTH_CHECK' // 网页侧检查扩展是否已配对
| 'DIANSHAN_SSO_HANDOFF' // 网页侧回传 extension token配对结果
| 'DIANSHAN_START_CRAWL' // 从网页发起爬取
| 'DIANSHAN_GET_CRAWL_STATE' // 获取当前进度
| 'DIANSHAN_CANCEL_CRAWL' // 取消爬取
@@ -22,6 +25,10 @@ interface ExternalMessage {
action?: ExternalAction;
payload?: {
platformId?: string;
// 中文备注:网页侧配对后回传的登录信息(全部用 any按要求不定义复杂类型
authState?: any;
// 中文备注:兼容网页侧老参数(例如 ingestContext扩展侧目前不使用但保留以免网页传多字段报错。
[key: string]: any;
};
}
@@ -69,6 +76,38 @@ export async function handleExternalMessage(message: ExternalMessage): Promise<E
case 'DIANSHAN_START_CRAWL':
return startCrawlForWebsite(message.payload?.platformId);
// 网页侧检查扩展是否已配对(是否已存 extension token
case 'DIANSHAN_AUTH_CHECK': {
const res = await chrome.storage.local.get("auth_state");
const auth: any = res?.["auth_state"];
return {
ok: true,
success: true,
type: "DIANSHAN_AUTH_STATE",
data: {
authed: !!auth?.token,
userEmail: auth?.userEmail ?? null,
},
};
}
// 网页侧把配对得到的 extension token 回传给扩展
case 'DIANSHAN_SSO_HANDOFF': {
const authState = message.payload?.authState;
if (!authState?.token) {
return {ok: false, error: 'missing_extension_token'};
}
await setAuthState(authState);
return {
ok: true,
success: true,
type: "DIANSHAN_SSO_OK",
data: {saved: true},
};
}
// 主动查询当前进度
case 'DIANSHAN_GET_CRAWL_STATE':
return {
@@ -167,7 +206,8 @@ export function broadcastCrawlStorageChange(changes: Record<string, chrome.stora
*/
async function startCrawlForWebsite(platformId?: string): Promise<ExternalResponse<CrawlWebPayload>> {
// 调用核心爬取逻辑
const response: any = await startCrawl(platformId ?? platformConfigs[0]?.id ?? '');
// 中文备注网页侧触发属于手动触发manual
const response: any = await startCrawl(platformId ?? platformConfigs[0]?.id ?? '', "manual");
// 检查返回的是错误对象还是成功的任务状态
const isError = response && typeof response === 'object' && 'error' in response && !('id' in response);
@@ -245,4 +285,4 @@ function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse
/** 类型守卫:判断对象是否为有效的任务状态 */
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
}
}

View File

@@ -0,0 +1,214 @@
/**
* 把“爬取的 raw 结果”组装成后端 ingest/scan 需要的 payload。
* 中文备注:逻辑参考 `my-app/src/utils/extension/scan_payload.ts`,但这里运行在扩展侧,直接上传到后端。
*
* 按你的要求:不定义一堆类型,参数/返回统一用 any。
*/
export function buildIngestScanBody(state: any, rawResult: any, auth: any, trigger: any): any {
const brandId = auth?.brandId;
const storeId = auth?.storeId;
if (!brandId) {
throw new Error("缺少 brandId请先在网页端完成品牌配置并重新配对扩展。");
}
if (!storeId) {
throw new Error("缺少 storeId请先在网页端完成店铺配置并重新配对扩展。");
}
const scannedAt = new Date().toISOString();
const databoard = readStepResult(rawResult, "databoard");
const adscenter = readStepResult(rawResult, "adscenter");
const reviews = readStepResult(rawResult, "message");
const accountHealth = readStepResult(rawResult, "accounthealth");
const business = readObject(databoard["商业分析"]);
return {
brandId,
storeId,
scannedAt,
extractorStatus: getExtractorStatus(state),
extractorErrors: getExtractorErrors(state),
trigger: trigger || "manual",
platformAccountId: auth?.platformAccountId ?? null,
fieldMeta: {},
payload: {
store_id: storeId,
scanned_at: scannedAt,
today: buildTodayPayload(business),
recent_3h: [],
skus: [],
ads: buildAdsPayload(adscenter),
reviews: buildReviewsPayload(reviews, scannedAt),
competitors: [],
review_summary: null,
product_aggregates: null,
traffic_sources: [],
shop_health: buildShopHealthPayload(accountHealth),
},
};
}
function buildTodayPayload(business: any) {
const sales = readMetric(business["销售"]);
const visitors = readMetric(business["访客数"]);
const productClicks = readMetric(business["Product Clicks"]);
const orders = readMetric(business["订单"]);
const conversionRate = readMetric(business["Order Conversion Rate"]);
return {
orders: parseInteger(orders.value),
gmv_cents: parseMoneyCents(sales.value),
cancel_rate: 0,
return_rate: 0,
gmv_delta_yesterday_pct: parsePercent(sales.change),
gmv_net_cents: null,
conversion_rate: parsePercent(conversionRate.value),
visitors: parseNullableInteger(visitors.value),
product_clicks: parseNullableInteger(productClicks.value),
aov_cents: null,
orders_delta_yesterday_pct: parsePercent(orders.change),
conversion_rate_delta_yesterday_pct: parsePercent(conversionRate.change),
visitors_delta_yesterday_pct: parsePercent(visitors.change),
product_clicks_delta_yesterday_pct: parsePercent(productClicks.change),
aov_delta_yesterday_pct: null,
};
}
function buildAdsPayload(adscenter: any) {
const rows = readArray(adscenter["进行中广告列表"]);
return rows.map((item: any, index: number) => {
const row = readObject(item);
const info = readObject(row["广告信息"]);
const name = stringify(info["广告名称"]) || `campaign-${index + 1}`;
return {
campaign_id: name,
campaign_name: name,
type: stringify(info["广告类型"]) || "unknown",
state: "ongoing",
spend_cents: parseMoneyCents(row["花费"]),
clicks: 0,
orders: 0,
revenue_cents: parseMoneyCents(row["销售额"]),
impressions: 0,
roas: parseNumber(row["广告支出回报率"]) ?? 0,
target_roas: parseNumber(row["目标ROAS"]),
daily_budget_cents: parseMoneyCents(row["每日预算"]),
keywords: [],
};
});
}
function buildReviewsPayload(reviews: any, scannedAt: string) {
const rows = readArray(reviews["低星评论"]);
return rows.map((item: any, index: number) => {
const row = readObject(item);
const orderId = stringify(row["订单编号"]);
return {
review_id: orderId || `review-${index + 1}`,
sku_id: null,
rating: 1,
text: stringify(row["评价内容"]) || stringify(row["商品名称"]) || "",
replied: false,
created_at: scannedAt,
};
});
}
function buildShopHealthPayload(accountHealth: any) {
const healthRows = readArray(accountHealth["健康状态"]);
if (healthRows.length === 0) return null;
return {
rating_overall: null,
penalty_points: null,
metrics: {},
};
}
function getExtractorStatus(state: any): any {
if (!state || state.status === "failed" || state.status === "canceled") {
return "failed";
}
const hasFailedStep = Array.isArray(state.steps) && state.steps.some((step: any) => step.status === "failed");
const hasSuccessStep = Array.isArray(state.steps) && state.steps.some((step: any) => step.status === "success");
if (hasFailedStep && hasSuccessStep) return "partial";
if (hasFailedStep) return "failed";
return "ok";
}
function getExtractorErrors(state: any) {
if (!state) return ["missing extension crawl state"];
const list = Array.isArray(state.steps) ? state.steps : [];
return list
.filter((step: any) => step.status === "failed" || step.message)
.map((step: any) => `${step.name}: ${step.message || step.status}`)
.slice(0, 50);
}
function readStepResult(result: any, key: string) {
return readObject(readObject(result?.[key])["result"]);
}
function readMetric(value: any) {
const item = readObject(value);
return {value: item.value, change: item.change};
}
function readObject(value: any) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function readArray(value: any) {
return Array.isArray(value) ? value : [];
}
function stringify(value: any) {
return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
}
function parseInteger(value: any) {
return Math.max(0, Math.round(parseNumber(value) ?? 0));
}
function parseNullableInteger(value: any) {
const parsed = parseNumber(value);
return parsed == null ? null : Math.max(0, Math.round(parsed));
}
function parseMoneyCents(value: any) {
const parsed = parseNumber(value);
return Math.max(0, Math.round((parsed ?? 0) * 100));
}
function parsePercent(value: any) {
const text = stringify(value);
if (!text) return null;
const parsed = parseNumber(text);
if (parsed == null) return null;
return text.includes("%") ? parsed / 100 : parsed;
}
function parseNumber(value: any) {
const text = stringify(value)
.replace(/RM/gi, "")
.replace(/,/g, "")
.replace(/%/g, "")
.replace(/[^\d.-]/g, "");
if (!text || text === "-" || text === "." || text === "-.") return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@@ -1,25 +1,53 @@
import {getPlatformById} from "@/config/platforms";
import {CrawlTaskState, PlatformStepConfig} from "@/types";
import type {CrawlTaskState, PlatformStepConfig} from "@/types";
import {openSingleTabWindow, scrapeStepInContent, sleep, waitForTabLoaded} from "@/background/task/helper";
import {clearCrawlTaskState, getCrawlTaskState, setCrawlTaskState, updateCrawlTaskState} from "./taskState";
import {sendTabMessage} from "@/shared/tab";
import {getAuthState} from "@/shared/store";
import {ingestScanApi} from "@/api/scan";
import {buildIngestScanBody} from "@/background/service/scan_payload";
import {getScanQuotaApi} from "@/api/me";
const activeCrawlControllers = new Map<string, AbortController>();
/**
* 创建新的爬取任务打开目标平台窗口,并把初始时间轴状态写入 storage
* @param platformId 平台id
* 创建新的爬取任务打开目标平台窗口,并把初始状态写入 storage
* @param platformId 平台 id
* @param trigger 触发来源manual/scheduled按 API 文档)
*/
export async function startCrawl(platformId: string): Promise<any> {
const platform = getPlatformById(platformId);
if (!platform) {
return {error: '平台配置不存在'};
export async function startCrawl(platformId: string, trigger: any = "manual"): Promise<any> {
// 中文备注:没有 extension token 就不允许开始爬取,否则最后上传会失败(也不符合产品预期)
const auth = getAuthState();
if (!auth?.token) {
return {error: "not_authed"};
}
//打开窗口
let windowInfo = await openSingleTabWindow(platform.steps[0].url)
//初始化数据
// 中文备注:开始爬取前先向后端确认订阅/权限(扩展 token 可访问的接口GET /api/me/scan-quota
// - manual必须 allowed=true 才允许开始
// - scheduled允许忽略“手动次数上限”等限制但订阅过期subscription_required必须阻断
try {
const quota: any = await getScanQuotaApi();
const allowed = quota?.allowed === true;
const reason = String(quota?.reason || "");
if (String(trigger) === "manual" && !allowed) {
return {error: reason || quota?.message || "scan_not_allowed"};
}
if (String(trigger) === "scheduled" && reason === "subscription_required") {
return {error: "subscription_required"};
}
} catch (e: any) {
// 中文备注:如果接口失败,为了避免“订阅已过期但仍可扫描”的漏洞,这里选择阻断启动
return {error: e?.response?.data?.message || e?.message || "quota_check_failed"};
}
const platform = getPlatformById(platformId);
if (!platform) {
return {error: "platform_not_found"};
}
// 打开爬取窗口(取第一步 URL 作为入口)
const windowInfo = await openSingleTabWindow(platform.steps[0].url);
const startedAt = Date.now();
const nextState: CrawlTaskState = {
id: `${platform.id}-${startedAt}`,
@@ -28,37 +56,33 @@ export async function startCrawl(platformId: string): Promise<any> {
platformId: platform.id,
platformName: platform.name,
startedAt,
status: 'running',
status: "running",
currentStepIndex: 0,
steps: platform.steps.map((item, index) => {
return {
name: item.name,
uniqueKey: item.uniqueKey,
status: index === 0 ? 'running' : 'pending',
}
})
steps: platform.steps.map((item, index) => ({
name: item.name,
uniqueKey: item.uniqueKey,
status: index === 0 ? "running" : "pending",
})),
};
// 中文备注CrawlTaskState 没有 trigger 字段(保持类型最小改动),这里用 any 写入,后续上传时会读取。
(nextState as any).trigger = trigger;
await setCrawlTaskState(nextState);
//写入任务,用于取消
// 写入任务执行器 controller用于取消/窗口关闭暂停)
const controller = new AbortController();
activeCrawlControllers.set(nextState.id, controller);
//启动
void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal).finally(() => {
activeCrawlControllers.delete(nextState.id);
});
//自动开始爬取
return nextState
return nextState;
}
/**
* 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取进度更新
* @param steps 平台步骤配置
* @param signal 中断信号
*/
/**
* 执行器
* 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取进度更新
*/
async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepConfig[], signal: AbortSignal, startIndex = 0) {
// 中文备注startIndex 用于“继续/恢复”场景,从上次没爬完的步骤开始跑。
@@ -66,49 +90,47 @@ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepC
const step = steps[i];
let shouldRetryStep = true;
// 【修改 2】进入新步骤立刻更新状态机里的索引和步骤状态
await updateCrawlTaskState(taskId, s => ({
// 进入新 step更新 currentStepIndex + step 状态
await updateCrawlTaskState(taskId, (s) => ({
...s,
currentStepIndex: i,
steps: s.steps.map((stepItem, idx) => ({
...stepItem,
status: idx === i ? 'running' : stepItem.status
}))
status: idx === i ? "running" : stepItem.status,
})),
}));
while (shouldRetryStep) {
if (signal.aborted) return;
// 1. 等待网页加载
// 1) 跳转到目标 URL 并等待加载完成
await chrome.tabs.update(tabId, {url: step.url, active: true});
const loaded = await waitForTabLoaded(tabId, signal);
if (!loaded) return;
// 2. 检测撞盾/抓取
// 2) 交给 content script 抓取
const res: any = await scrapeStepInContent(tabId, step, signal);
if (signal.aborted) return;
// 3. 处理中断(验证码等)
// 3) 处理“需要人工介入”的中断(登录/验证码/页面不存在等)
if (res.interrupt) {
await updateCrawlTaskState(taskId, s => ({...s, status: 'paused', pause: res.interrupt}));
await updateCrawlTaskState(taskId, (s) => ({...s, status: "paused", pause: res.interrupt}));
// 死等恢复
while ((await getCrawlTaskState())?.status === 'paused') {
// 死等恢复:直到 UI 把状态从 paused 改回 running
while ((await getCrawlTaskState())?.status === "paused") {
if (signal.aborted) return;
if (!(await sleep(1000, signal))) return;
}
continue; // 恢复后重新触发 while 循环(重刷页面)
continue;
}
// 4. 处理结果
// 4) 处理抓取结果
if (res.ok) {
await updateCrawlTaskState(taskId, s => ({
await updateCrawlTaskState(taskId, (s) => ({
...s,
steps: s.steps.map((item, idx) =>
idx === i ? {...item, status: 'success', result: res.data} : item
)
steps: s.steps.map((item, idx) => (idx === i ? {...item, status: "success", result: res.data} : item)),
}));
shouldRetryStep = false; // 退出 while准备进下一个 for 循环步骤
shouldRetryStep = false;
} else {
// 抓取失败重试
if (!(await sleep(2000, signal))) return;
@@ -116,52 +138,64 @@ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepC
}
}
// 【修改 3】全部步骤完成标记任务结束
await updateCrawlTaskState(taskId, s => ({...s, status: 'completed'}));
// 中文备注:全部爬取完成后,需要把数据发送给网页,然后清空本次任务记录数据、关掉爬取窗口。
// 这里由 background 统一做“完成后收尾”,避免 UI 侧各自处理导致状态不同步。
// 全部步骤完成标记 completed 并统一收尾(上传 -> 清理 -> 关窗)
await updateCrawlTaskState(taskId, (s) => ({...s, status: "completed"}));
await finalizeCompletedTask(taskId, signal);
}
/**
* 取消当前爬取任务,并尝试关闭正在爬取的平台窗口
* 取消当前爬取任务:停止执行器、清空记录、关闭爬取窗口
*/
export async function cancelCrawl() {
const state = await getCrawlTaskState();
if (!state) return;
if (!state) return
// 立即触发 Abort 信号,让脚本自动停止
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
activeCrawlControllers.delete(state.id);
}
//清楚缓存
await clearCrawlTaskState();
//关闭窗口
if (state.windowId) {
chrome.windows.remove(state.windowId).catch(() => {
});
chrome.windows.remove(state.windowId).catch(() => undefined);
}
}
/**
* 当爬取窗口被用户手动关闭时触发:把任务标记为暂停,并中止当前的执行器。
* 中文备注这里“暂停”不是取消任务进度steps/result/currentStepIndex会保留供后续“继续”恢复。
* 关闭/忽略当前任务的 UI 提示:只清空状态并尝试关闭窗口(不额外走取消流程)
*/
export async function dismissCrawl(): Promise<void> {
const state = await getCrawlTaskState();
if (!state) {
await clearCrawlTaskState();
return;
}
// 中文备注如果还有执行器在跑dismiss 等同取消,避免后台继续执行。
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
activeCrawlControllers.delete(state.id);
}
await clearCrawlTaskState();
if (state.windowId) {
chrome.windows.remove(state.windowId).catch(() => undefined);
}
}
/**
* 用户手动关闭爬取窗口:自动暂停任务,并中止当前执行器
*/
export async function pauseCrawlOnWindowRemoved(windowId: number): Promise<void> {
const state = await getCrawlTaskState();
if (!state) return;
if (state.status !== 'running') return;
if (state.status !== "running") return;
if (state.windowId !== windowId) return;
// 中文备注:窗口被关掉后继续跑会频繁报 tab 不存在;这里直接 abort 当前 controller等待用户点击“继续”后重启。
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
@@ -170,28 +204,26 @@ export async function pauseCrawlOnWindowRemoved(windowId: number): Promise<void>
await updateCrawlTaskState(state.id, (s) => ({
...s,
status: 'paused',
status: "paused",
pause: {
reason: 'window_closed',
message: '检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。',
reason: "window_closed",
message: "检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。",
},
// 中文备注:窗口/tab 已不存在,置空避免 UI 侧再尝试聚焦旧窗口。
// 中文备注:窗口/tab 已不存在,置空避免 UI 侧再尝试聚焦旧窗口。
windowId: undefined,
tabId: undefined,
}));
}
/**
* 爬取 tab 被关闭时触发:同样按“窗口被关闭”处理。
* 中文备注:有些情况下只会触发 tabs.onRemoved这里单独兜底。
* 爬取 tab 被关闭:同样按“窗口被关闭”暂停处理(兜底)
*/
export async function pauseCrawlOnTabRemoved(tabId: number): Promise<void> {
const state = await getCrawlTaskState();
if (!state) return;
if (state.status !== 'running') return;
if (state.status !== "running") return;
if (state.tabId !== tabId) return;
// 直接复用 window 关闭的暂停逻辑windowId 可能为空,但不影响暂停)
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
@@ -200,10 +232,10 @@ export async function pauseCrawlOnTabRemoved(tabId: number): Promise<void> {
await updateCrawlTaskState(state.id, (s) => ({
...s,
status: 'paused',
status: "paused",
pause: {
reason: 'window_closed',
message: '检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。',
reason: "window_closed",
message: "检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。",
},
windowId: undefined,
tabId: undefined,
@@ -211,38 +243,32 @@ export async function pauseCrawlOnTabRemoved(tabId: number): Promise<void> {
}
/**
* 继续/恢复暂停任务
* 中文备注:
* - 如果是登录/验证码导致的暂停:只需要把状态从 paused 切回 running让原来的执行器继续跑不重启
* - 如果是窗口被关闭导致的暂停:需要重新打开窗口,并从上次没完成的步骤开始重新跑。
* 继续/恢复暂停任务
*/
export async function resumeCrawl(): Promise<CrawlTaskState | null> {
const state = await getCrawlTaskState();
if (!state) return null;
if (state.status !== 'paused') {
if (state.status !== "paused") {
return state;
}
// 1) 登录/验证码等中断:窗口仍存在时,直接恢复即可
if (state.pause?.reason !== 'window_closed' && state.windowId && state.tabId) {
await updateCrawlTaskState(state.id, (s) => ({...s, status: 'running', pause: undefined}));
// 1) 登录/验证码等中断:窗口还在,直接恢复 running 即可
if (state.pause?.reason !== "window_closed" && state.windowId && state.tabId) {
await updateCrawlTaskState(state.id, (s) => ({...s, status: "running", pause: undefined}));
return await getCrawlTaskState();
}
// 2) 窗口关闭导致的暂停:重新打开窗口并从上次进度继续
// 2) 窗口关闭导致的暂停:需要重开窗口并从未完成 step 继续
const platform = getPlatformById(state.platformId);
if (!platform) {
// 中文备注:平台配置找不到时只能保持暂停态
return state;
}
if (!platform) return state;
const resumeIndex = Math.max(0, Math.min(state.currentStepIndex ?? 0, platform.steps.length - 1));
// 中文备注:如果 currentStepIndex 对应 step 已经 success说明暂停发生在步骤切换间隙往后找第一个未完成的步骤。
// 中文备注: currentStepIndex 往后找第一个未 success 的 step
let startIndex = resumeIndex;
for (let i = resumeIndex; i < state.steps.length; i += 1) {
if (state.steps[i]?.status !== 'success') {
if (state.steps[i]?.status !== "success") {
startIndex = i;
break;
}
@@ -255,19 +281,18 @@ export async function resumeCrawl(): Promise<CrawlTaskState | null> {
...state,
windowId: windowInfo.windowId,
tabId: windowInfo.tabId,
status: 'running',
status: "running",
pause: undefined,
currentStepIndex: startIndex,
steps: state.steps.map((step, idx) => ({
...step,
// 中文备注:继续时把当前要执行的 step 标为 runningsuccess 不动,避免覆盖已完成步骤)
status: idx === startIndex && step.status !== 'success' ? 'running' : step.status,
// 中文备注:继续时把当前要执行的 step 标为 running,已 success 不动
status: idx === startIndex && step.status !== "success" ? "running" : step.status,
})),
};
await setCrawlTaskState(nextState);
// 中文备注:重启执行器,从 startIndex 开始继续跑
const controller = new AbortController();
activeCrawlControllers.set(nextState.id, controller);
void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal, startIndex).finally(() => {
@@ -278,45 +303,41 @@ export async function resumeCrawl(): Promise<CrawlTaskState | null> {
}
/**
* 关闭/忽略当前任务的 UI 提示(只清空状态,不强制走取消逻辑)。
* 中文备注:用于 UI 侧把卡片隐藏掉;如果窗口还存在也会顺手关闭,避免残留。
*/
export async function dismissCrawl(): Promise<void> {
const state = await getCrawlTaskState();
if (!state) {
await clearCrawlTaskState();
return;
}
// 中文备注如果仍有执行器在跑dismiss 等同取消,避免后台继续执行。
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
activeCrawlControllers.delete(state.id);
}
await clearCrawlTaskState();
if (state.windowId) {
chrome.windows.remove(state.windowId).catch(() => {
});
}
}
/**
* 完成后的统一收尾:发送结果 -> 清空 storage -> 关闭爬取窗口
* 中文备注:
* - “发送给网页”外部网页externally_connectable会通过 storage 广播拿到 completed 状态和结果;
* - 同时也给爬取 tab 发一份 `CRAWL_COMPLETED`方便页面内content script有需要时直接接收。
* 完成后的统一收尾:扩展直传后端 -> 广播完成 -> 清空任务 -> 关窗
*/
async function finalizeCompletedTask(taskId: string, signal: AbortSignal) {
const state = await getCrawlTaskState();
if (!state || state.id !== taskId) return;
if (state.status !== 'completed') return;
if (state.status !== "completed") return;
// 1) 发送给爬取 tab如果 tab 还存在且页面内有监听方
// 中文备注:按新需求,爬取完成后由扩展直接提交到后端(不再交给网页提交
try {
const auth = getAuthState();
const rawResult = collectStepResults(state);
const trigger = (state as any).trigger || "manual";
const body = buildIngestScanBody(state, rawResult, auth, trigger);
await ingestScanApi(body);
} catch (e: any) {
const errorText =
e?.response?.data?.message
|| e?.response?.data?.data?.apiCode
|| e?.message
|| "上传失败";
await updateCrawlTaskState(taskId, (s) => ({
...s,
status: "failed",
// 中文备注:把上传失败原因挂到当前 step 的 message 上popup/overlay 可以直接展示。
steps: s.steps.map((step, idx) => (idx === s.currentStepIndex ? {...step, message: String(errorText)} : step)),
}));
console.error("[crawl] ingest/scan failed:", e);
return;
}
// 1) 给爬取 tab 发一个事件(如页面内有监听可直接接收)
if (state.tabId) {
sendTabMessage(state.tabId, 'CRAWL_COMPLETED', {
sendTabMessage(state.tabId, "CRAWL_COMPLETED", {
taskId: state.id,
platformId: state.platformId,
platformName: state.platformName,
@@ -325,24 +346,18 @@ async function finalizeCompletedTask(taskId: string, signal: AbortSignal) {
});
}
// 2) 留一点时间 storage.onChanged -> external ports 广播完成态DIANSHAN_CRAWL_DONE
// 中文备注:不宜太久,避免完成后窗口迟迟不关;这里 300ms 足够让消息出队。
// 2) 留一点时间 storage 广播完成态出队
await sleep(300, signal);
// 3) 清空任务记录popup 会收到 storage 变化自动重置 UI
// 3) 清空任务记录popup/overlay 会收到 storage 变化自动重置 UI
await clearCrawlTaskState();
// 4) 关闭爬取窗口
if (state.windowId) {
chrome.windows.remove(state.windowId).catch(() => {
});
chrome.windows.remove(state.windowId).catch(() => undefined);
}
}
/**
* 收集每个 step 的结果数据,统一输出为 { [uniqueKey]: { ... } } 结构。
* 中文备注:该结构与 externalBridge.ts 里对外输出一致,方便网页侧消费。
*/
function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
return Object.fromEntries(
state.steps.map((step) => [
@@ -356,4 +371,3 @@ function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
]),
);
}

12
src/config.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* 扩展侧构建时配置。
* 中文备注:
* - `APP_URL` 用于“登录按钮”打开的网站地址(也用于外部网页与扩展通信的可信来源)。
* - 默认使用本地开发地址 `http://localhost:3000`。
*/
export const APP_URL: string =
(import.meta as any).env?.VITE_APP_URL ?? "http://localhost:3000";
export const APP_ORIGIN: string = new URL(APP_URL).origin;

View File

@@ -19,6 +19,10 @@ const {
handleScan,
handleCancelCrawl,
handleResumeCrawl,
quotaLoading,
subscriptionExpired,
quotaDeniedMessage,
openBillingPage,
} = useScan();
console.log(crawlState.value)
@@ -98,9 +102,21 @@ function onLangChange(event: Event): void {
</select>
</label>
<button type="button" :disabled="isScanning" @click="handleScan">
{{ isScanning ? t('opening') : t('scan_now') }}
</button>
<!-- 中文备注订阅到期时不允许用户在扩展里直接点立即扫描绕过订阅限制 -->
<template v-if="subscriptionExpired">
<div class="status">{{ t('subscription_expired') }}</div>
<button type="button" @click="openBillingPage">
{{ t('go_billing') }}
</button>
</template>
<template v-else>
<button type="button" :disabled="isScanning || quotaLoading" @click="handleScan">
{{ quotaLoading ? t('checking_sub') : (isScanning ? t('opening') : t('scan_now')) }}
</button>
<div v-if="quotaDeniedMessage" class="status" style="margin-top: 8px;">
{{ quotaDeniedMessage || t('scan_disabled') }}
</div>
</template>
</template>
<!-- 进行中-->

View File

@@ -19,6 +19,10 @@ const DICT: Record<PopupUiLang, Record<string, string>> = {
sign_out: '退出登录',
platform_select: '平台选择',
scan_now: '立即扫描',
checking_sub: '正在检查订阅…',
subscription_expired: '订阅已到期,扫描功能已锁定。',
go_billing: '去订阅/续费',
scan_disabled: '暂时不允许扫描。',
opening: '正在打开…',
scanning: '扫描中',
paused: '已暂停',
@@ -41,6 +45,10 @@ const DICT: Record<PopupUiLang, Record<string, string>> = {
sign_out: 'Sign out',
platform_select: 'Platform',
scan_now: 'Scan now',
checking_sub: 'Checking subscription…',
subscription_expired: 'Subscription expired. Scanning is locked.',
go_billing: 'Upgrade / Renew',
scan_disabled: 'Scan not allowed right now.',
opening: 'Opening…',
scanning: 'Scanning',
paused: 'Paused',
@@ -109,4 +117,3 @@ export function useI18n() {
],
};
}

View File

@@ -1,37 +1,52 @@
import { computed, onMounted, ref } from 'vue';
import { getToken, logout, setToken } from '@/shared/auth';
import {computed, onMounted, ref} from "vue";
import {APP_URL} from "@/config";
import {clearAuthState, getAuthState, initAuthState} from "@/shared/store";
/**
* Popup 的登录状态与操作
* Popup 的登录状态与操作
*
* 中文备注:
* - 扩展侧不直接拿 Web token登录/配对在网页侧完成
* - 网页侧拿到 extension token 后,会通过 `onMessageExternal` 回传并写入 `chrome.storage.local['auth_state']`
*/
export const useLogin = () => {
const token = ref<string | null>(null);
/** 当前是否已登录。 */
// 当前是否已登录(是否已有 extension token
const isLoggedIn = computed(() => token.value !== null);
/**
* 登录并保存 token
* 登录:打开网页,让网页完成配对并回传 extension token
*/
const handleLogin = async () => {
const value = 'xxx';
await setToken(value);
token.value = value;
const extId = chrome?.runtime?.id || "";
const url = `${APP_URL.replace(/\/$/, "")}/onboarding/extension?from=extension&extId=${encodeURIComponent(extId)}`;
await chrome.tabs.create({url, active: true});
};
/**
* 退出登录并清理本地状态。
* 退出:清空本地 token 与上下文
*/
const handleLogout = async () => {
await logout();
await clearAuthState();
token.value = null;
};
/**
* 组件挂载时,从存储恢复 token。
*/
onMounted(async () => {
token.value = await getToken();
// 中文备注:先恢复内存态 authState再读 token 判断是否已配对
await initAuthState();
token.value = getAuthState()?.token ?? null;
// 中文备注:监听 storage 变化,网页回传 token 后popup 自动刷新“已登录”状态
if (chrome?.storage?.onChanged) {
const handler = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== "local") return;
const change = changes["auth_state"];
if (!change) return;
token.value = (change.newValue as any)?.token ?? null;
};
chrome.storage.onChanged.addListener(handler);
}
});
return {

View File

@@ -1,34 +1,40 @@
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {platformConfigs} from '@/config/platforms';
import type {CrawlTaskState} from '@/types';
import {sendBackgroundMessage} from '@/shared/message';
import {computed, onMounted, onUnmounted, ref} from "vue";
import {platformConfigs} from "@/config/platforms";
import type {CrawlTaskState} from "@/types";
import {sendBackgroundMessage} from "@/shared/message";
import {getScanQuotaApi} from "@/api/me";
import {APP_URL} from "@/config";
/**
* Popup 内的爬取状态与操作集合
* Popup 内的爬取状态与操作集合
*
* 中文备注:
* - 订阅存在“隐藏逻辑”:订阅到期时不能让用户绕过网页,直接在扩展 popup 点“立即扫描”
* - 这里会在 popup 打开时调用一次 `/api/me/scan-quota`,如果订阅过期则隐藏“立即扫描”按钮,改为引导去订阅
*/
export const useScan = () => {
/** 当前选中的平台 id */
const selectedPlatformId = ref(platformConfigs[0]?.id ?? '');
/** 防止重复点击“Scan now”(打开扫描窗口期间置为 true */
/** 当前选中的平台 id */
const selectedPlatformId = ref(platformConfigs[0]?.id ?? "");
/** 防止重复点击 “Scan now” */
const isScanning = ref<boolean>(false);
/**
* 当前爬取任务状态(从 background 同步)。
*/
/** 当前爬取任务状态(从 background 同步) */
const crawlState = ref<CrawlTaskState | null>(null);
const taskStatus = computed(() => crawlState.value?.status);
/**
* 从任务开始到现在的秒数。
*/
const elapsedSeconds = ref<number>(0);
/** 任务耗时(秒) */
const elapsedSeconds = ref<number>(0);
let timer: number | undefined;
// 中文备注:订阅/权限检查(控制“立即扫描”按钮是否可用)
const quotaLoading = ref<boolean>(false);
const quotaDeniedReason = ref<string | null>(null);
const quotaDeniedMessage = ref<string | null>(null);
const subscriptionExpired = computed(() => quotaDeniedReason.value === "subscription_required");
/**
* 停止计时器,并把显示的耗时归零。
* 中文备注:crawlState 被清空(例如取消/完成后清理)时,如果不清理定时器,
* 定时器回调会继续访问 crawlState!.startedAt导致 popup 直接崩溃,看起来像“闪一下就没反应”。
* 停止计时器并清零
* 中文备注crawlState 被清空时必须停表,否则 timer 回调会访问空对象导致 popup “闪一下”
*/
function stopElapsedTimer() {
if (timer) {
@@ -38,134 +44,159 @@ export const useScan = () => {
elapsedSeconds.value = 0;
}
function startElapsedTimer() {
if (crawlState.value === null || timer) return;
timer = window.setInterval(() => {
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value!.startedAt) / 1000));
}, 1000);
}
function syncCrawlState(state: CrawlTaskState | null) {
crawlState.value = state;
if (state === null) {
stopElapsedTimer();
return;
}
startElapsedTimer();
}
async function refreshCrawlState() {
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "GET_CRAWL_STATE"});
if (response.ok) {
syncCrawlState(response.data ?? null);
}
}
/**
* 动新的爬取任务
* 打开订阅页面(网页侧)
*/
const handleScan = async () => {
if (isScanning.value) {
async function openBillingPage() {
const url = `${APP_URL.replace(/\/$/, "")}/dashboard/billing`;
await chrome.tabs.create({url, active: true});
}
/**
* 刷新订阅/权限信息(扩展 token 鉴权)
*/
async function refreshQuota() {
if (quotaLoading.value) return;
// 中文备注:未登录(无 auth_state时不请求交给 UI 显示“请登录”即可
const stored: any = await chrome.storage.local.get("auth_state");
if (!stored?.auth_state?.token) {
quotaDeniedReason.value = null;
quotaDeniedMessage.value = null;
return;
}
quotaLoading.value = true;
try {
const quota: any = await getScanQuotaApi();
if (quota?.allowed === true) {
quotaDeniedReason.value = null;
quotaDeniedMessage.value = null;
} else {
quotaDeniedReason.value = String(quota?.reason || "scan_not_allowed");
quotaDeniedMessage.value = String(quota?.message || "");
}
} catch (e: any) {
// 中文备注:接口失败时不在 UI 里误判为“订阅到期”,只记录为不可扫描
quotaDeniedReason.value = "quota_check_failed";
quotaDeniedMessage.value = e?.response?.data?.message || e?.message || "";
} finally {
quotaLoading.value = false;
}
}
/**
* 触发扫描(开始前会先做订阅校验)
*/
const handleScan = async () => {
if (isScanning.value) return;
isScanning.value = true;
try {
// 中文备注:点击“立即扫描”前先刷新一次订阅/权限,避免订阅刚到期但按钮仍可点
await refreshQuota();
if (subscriptionExpired.value) {
await openBillingPage();
return;
}
if (quotaDeniedReason.value) {
console.error("[crawl] scan blocked:", quotaDeniedReason.value, quotaDeniedMessage.value);
return;
}
const response = await sendBackgroundMessage<CrawlTaskState>({
action: 'START_CRAWL',
action: "START_CRAWL",
payload: {platformId: selectedPlatformId.value},
});
if (response.ok) {
syncCrawlState(response.data ?? null);
} else {
console.error('[crawl] start failed', response.error);
console.error("[crawl] start failed", response.error);
}
} finally {
isScanning.value = false;
}
};
/** 通知 background 取消当前任务。 */
const handleCancelCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'CANCEL_CRAWL'});
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "CANCEL_CRAWL"});
if (response.ok) {
syncCrawlState(response.data ?? null);
return;
}
console.error('[crawl] cancel failed', response.error);
console.error("[crawl] cancel failed", response.error);
await refreshCrawlState();
};
/** 通知 background 恢复被暂停的任务。 */
const handleResumeCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'RESUME_CRAWL'});
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "RESUME_CRAWL"});
if (response.ok) {
syncCrawlState(response.data ?? null);
return;
}
console.error('[crawl] resume failed', response.error);
console.error("[crawl] resume failed", response.error);
await refreshCrawlState();
};
/** 关闭任务卡片并通知 background 清理任务状态。 */
const handleDismissCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'DISMISS_CRAWL'});
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "DISMISS_CRAWL"});
if (response.ok) {
syncCrawlState(response.data ?? null);
return;
}
console.error('[crawl] dismiss failed', response.error);
console.error("[crawl] dismiss failed", response.error);
await refreshCrawlState();
};
/**
* 设置状态值,并设置时间
* 监听 storage 变化:
* - crawlTaskState同步任务进度
* - auth_state登录/退出/重新配对后刷新订阅状态
*/
function syncCrawlState(state: CrawlTaskState | null) {
crawlState.value = state;
// 中文备注:任务被清空时,必须停止计时器,避免空引用导致 popup 闪退。
if (state === null) {
stopElapsedTimer();
return;
}
startElapsedTimer();
}
/**
* 启动定时器
*/
function startElapsedTimer() {
if (crawlState.value === null || timer) {
return;
}
timer = window.setInterval(() => {
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value!.startedAt) / 1000));
}, 1000);
}
/** 从 background 拉取最新任务状态。 */
async function refreshCrawlState() {
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'GET_CRAWL_STATE'});
if (response.ok) {
syncCrawlState(response.data ?? null);
}
}
/** 监听 `chrome.storage` 的变化,用于跨上下文同步任务状态。 */
function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) {
if (areaName !== 'local') {
return;
if (areaName !== "local") return;
if (changes["auth_state"]) {
void refreshQuota();
}
const change = changes["crawlTaskState"];
if (!change) {
return;
}
if (!change) return;
syncCrawlState(isCrawlTaskState(change.newValue) ? change.newValue : null);
}
onMounted(async () => {
/** 首次加载 + 订阅 storage 事件。 */
await refreshCrawlState();
await refreshQuota();
chrome.storage.onChanged.addListener(handleStorageChanged);
});
onUnmounted(() => {
/** 清理计时器 + 取消订阅 storage 事件。 */
stopElapsedTimer();
chrome.storage.onChanged.removeListener(handleStorageChanged);
});
@@ -174,15 +205,19 @@ export const useScan = () => {
isScanning,
crawlState,
taskStatus,
elapsedSeconds,
handleScan,
handleCancelCrawl,
handleResumeCrawl,
handleDismissCrawl,
elapsedSeconds,
// 订阅/权限
quotaLoading,
subscriptionExpired,
quotaDeniedMessage,
openBillingPage,
};
};
/** storage 数据的运行时类型保护(防御不可信数据)。 */
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
return typeof value === "object" && value !== null && "id" in value && "steps" in value;
}

View File

@@ -0,0 +1,98 @@
import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios";
import {getAuthState} from "@/shared/store";
/**
* axios 封装(扩展侧):要求与 `my-app/src/utils/reqeust.ts` 保持一致的行为。
*
* 中文备注:
* - 统一 baseURL来自配对后下发的 apiBaseUrl
* - 自动带上 Authorization: Bearer <extension_token>
* - 统一处理后端 `{ code, message, data }`:成功返回 data失败 reject 整个响应体
*/
const service = axios.create({
// 中文备注baseURL 会在请求拦截器里按需动态设置,这里先给空值
baseURL: "",
timeout: 30000,
});
// 请求拦截器
service.interceptors.request.use((config: any) => {
// 中文备注:当数据为 FormData 时,自动修改请求头(与网站端一致)
if (config.data instanceof FormData) {
(config as any).headers = {"Content-Type": "multipart/form-data"};
}
const auth = getAuthState();
if (auth?.apiBaseUrl) {
config.baseURL = normaliseApiBaseUrl(auth.apiBaseUrl);
}
if (auth?.token) {
(config.headers as any).Authorization = `Bearer ${auth.token}`;
}
// 中文备注扩展版本门禁API 文档要求 X-Ext-Version用于扫描相关接口
try {
const version = chrome.runtime.getManifest().version;
(config.headers as any)["X-Ext-Version"] = version;
} catch {
// ignore
}
return config;
}, (error: AxiosError) => Promise.reject(error));
// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {
const {code, data} = response.data || {};
// 中文备注:与网站端一致,成功码统一返回 data
if ([1, "200"].includes(code)) {
return data;
}
// 中文备注:当为文件流时直接返回(保留网站端兜底逻辑)
if ((response.headers as any)?.["content-types"] === "application/octet-stream") {
return response.data;
}
return Promise.reject(response.data);
}, (error: any) => {
if (error?.message === "Network Error") {
// 中文备注:这里不弹 Toast避免扩展侧打扰需要的话在 UI 层处理
}
return Promise.reject(error);
});
function normaliseApiBaseUrl(apiBaseUrl: string) {
const raw = String(apiBaseUrl || "").replace(/\/$/, "");
if (!raw) return "";
return raw.endsWith("/api") ? raw : `${raw}/api`;
}
function requestPost(url: string, data: any = {}, config: AxiosRequestConfig = {}) {
return service.post(url, data, config);
}
function requestPatch(url: string, data: any = {}, config: AxiosRequestConfig = {}) {
return service.patch(url, data, config);
}
function requestGet(url: string, params: any = {}) {
return service.get(url, {params});
}
function requestDelete(url: string, data: any = {}) {
return service.delete(url, {data});
}
const request = {
get: requestGet,
post: requestPost,
delete: requestDelete,
patch: requestPatch,
};
export default request;

57
src/shared/store.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 扩展侧的本地“状态仓库”(轻量版)。
* 中文备注:
* - axios 拦截器需要同步读取 token/apiBaseUrl所以这里维护一份内存态 authState。
* - 真实持久化仍然落在 chrome.storage.local确保扩展重启后可恢复。
* - 按需求:不要定义一堆类型,这里统一用 any。
*/
const AUTH_STATE_KEY = "auth_state";
let authState: any = null;
/**
* 同步读取内存态 authState给 axios 拦截器使用)。
*/
export function getAuthState() {
return authState;
}
/**
* 从 chrome.storage.local 初始化 authState建议在 background 启动时调用一次)。
*/
export async function initAuthState() {
if (typeof chrome === "undefined" || !chrome.storage?.local) {
return null;
}
const result = await chrome.storage.local.get(AUTH_STATE_KEY);
authState = result?.[AUTH_STATE_KEY] ?? null;
return authState;
}
/**
* 写入 authState内存 + 持久化)。
* 中文备注:配对成功后会在这里保存 extension token、apiBaseUrl、brand/store 等上下文。
*/
export async function setAuthState(next: any) {
authState = next ?? null;
if (typeof chrome === "undefined" || !chrome.storage?.local) {
return;
}
if (authState) {
await chrome.storage.local.set({[AUTH_STATE_KEY]: authState});
} else {
await chrome.storage.local.remove(AUTH_STATE_KEY);
}
}
/**
* 清空登录态(等价于退出登录)。
*/
export async function clearAuthState() {
await setAuthState(null);
}

View File

@@ -1 +1 @@
{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/externalbridge.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/request.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"}
{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/config.ts","./src/api/me.ts","./src/api/scan.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/autoscanscheduler.ts","./src/background/service/externalbridge.ts","./src/background/service/scan_payload.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/request.ts","./src/shared/store.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"}