mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update UI & Worker
This commit is contained in:
parent
a84f148743
commit
9aeb12c7bf
@ -20,4 +20,16 @@ if (USE_SIDE_PANEL) {
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Azon Seeker installed');
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: 'show-result',
|
||||
title: '结果页',
|
||||
contexts: ['action'],
|
||||
});
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener((info) => {
|
||||
if (info.menuItemId === 'show-result') {
|
||||
browser.runtime.openOptionsPage();
|
||||
}
|
||||
});
|
||||
|
||||
112
src/components/AsinsInput.vue
Normal file
112
src/components/AsinsInput.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import type { FormItemRule } from 'naive-ui';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const modelValue = defineModel<string>({ required: true });
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const formItemRef = useTemplateRef('detail-form-item');
|
||||
const formItemRule: FormItemRule = {
|
||||
required: true,
|
||||
trigger: ['submit', 'blur'],
|
||||
message: '请输入格式正确的ASIN',
|
||||
validator: () => {
|
||||
return modelValue.value.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const fileDialog = useFileDialog({ accept: '.txt', multiple: false });
|
||||
fileDialog.onChange((fileList) => {
|
||||
const file = fileList?.item(0);
|
||||
file && handleImportAsin(file);
|
||||
fileDialog.reset();
|
||||
});
|
||||
|
||||
const handleImportAsin = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result;
|
||||
if (typeof content === 'string') {
|
||||
modelValue.value = content;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file, 'utf-8');
|
||||
};
|
||||
|
||||
const handleExportAsin = () => {
|
||||
const blob = new Blob([modelValue.value], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = `asin-${new Date().toISOString()}.txt`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.info('导出完成');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
validate: async () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
formItemRef.value?.validate({
|
||||
trigger: 'submit',
|
||||
callback: (errors) => {
|
||||
if (errors) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asin-input">
|
||||
<n-space>
|
||||
<n-button @click="fileDialog.open()" :disabled="disabled" round size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
<n-button :disabled="disabled" @click="handleExportAsin" round size="small">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div style="height: 7px" />
|
||||
<n-form-item ref="detail-form-item" label-placement="left" :rule="formItemRule">
|
||||
<n-input
|
||||
:disabled="disabled"
|
||||
v-model:value="modelValue"
|
||||
placeholder="输入ASINs"
|
||||
type="textarea"
|
||||
size="large"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.asin-input {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -33,18 +33,34 @@ const props = defineProps<{ model: AmazonDetailItem }>();
|
||||
{{ link }}
|
||||
</div>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="props.model.topReviews" label="精选评论" :span="4">
|
||||
<div v-for="review in props.model.topReviews" style="margin-bottom: 5px">
|
||||
<h3 style="margin: 0">{{ review.username }}: {{ review.title }}</h3>
|
||||
<div style="color: gray; font-size: smaller">{{ review.rating }}</div>
|
||||
<div v-for="paragraph in review.content.split('\n')">
|
||||
{{ paragraph }}
|
||||
<n-descriptions-item
|
||||
v-if="props.model.topReviews && props.model.topReviews.length > 0"
|
||||
label="精选评论"
|
||||
:span="4"
|
||||
>
|
||||
<n-scrollbar style="max-height: 500px">
|
||||
<div class="review-item-cotent">
|
||||
<div v-for="review in props.model.topReviews" style="margin-bottom: 5px">
|
||||
<h3 style="margin: 0">{{ review.username }}: {{ review.title }}</h3>
|
||||
<div style="color: gray; font-size: smaller">{{ review.rating }}</div>
|
||||
<div v-for="paragraph in review.content.split('\n')">
|
||||
{{ paragraph }}
|
||||
</div>
|
||||
<div style="color: gray; font-size: smaller">{{ review.dateInfo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="color: gray; font-size: smaller">{{ review.dateInfo }}</div>
|
||||
</n-scrollbar>
|
||||
<div class="review-item-footer">
|
||||
<n-button size="small">Load More</n-button>
|
||||
</div>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.review-item-footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, UploadOnChange } from 'naive-ui';
|
||||
import { NButton } from 'naive-ui';
|
||||
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
|
||||
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types';
|
||||
import { allItems } from '~/logic/storage';
|
||||
import DetailDescription from './DetailDescription.vue';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const fileDialog = useFileDialog({
|
||||
accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
multiple: false,
|
||||
});
|
||||
fileDialog.onChange((files) => {
|
||||
const file = files?.item(0);
|
||||
file && handleImport(file);
|
||||
fileDialog.reset();
|
||||
});
|
||||
|
||||
const page = reactive({ current: 1, size: 10 });
|
||||
|
||||
const filter = reactive({
|
||||
keywords: null as string | null,
|
||||
search: '',
|
||||
@ -120,10 +132,14 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||
const itemView = computed<{ records: AmazonItem[]; pageCount: number; origin: AmazonItem[] }>(
|
||||
() => {
|
||||
const { current, size } = page;
|
||||
let data = filterItemData(allItems.value); // Filter Data
|
||||
let pageCount = ~~(data.length / size);
|
||||
pageCount += data.length % size > 0 ? 1 : 0;
|
||||
return { records: data.slice((current - 1) * size, current * size), pageCount, origin: data };
|
||||
const data = filterItemData(allItems.value); // Filter Data
|
||||
const pageCount = ~~(data.length / size) + (data.length % size > 0 ? 1 : 0);
|
||||
const offset = (current - 1) * size;
|
||||
if (data.length > 0 && offset >= data.length) {
|
||||
page.current = 1;
|
||||
}
|
||||
const records = data.slice(offset, offset + size);
|
||||
return { records, pageCount, origin: data };
|
||||
},
|
||||
);
|
||||
|
||||
@ -189,11 +205,7 @@ const handleExport = async () => {
|
||||
message.info('导出完成');
|
||||
};
|
||||
|
||||
const handleImport: UploadOnChange = async ({ fileList }) => {
|
||||
if (fileList.length !== 1 || fileList[0].file === null) {
|
||||
return;
|
||||
}
|
||||
const file: File = fileList.pop()!.file!;
|
||||
const handleImport = async (file: File) => {
|
||||
const headers: Header[] = columns
|
||||
.reduce(
|
||||
(p, v: Record<string, any>) => {
|
||||
@ -234,65 +246,66 @@ const handleClearData = async () => {
|
||||
size="small"
|
||||
placeholder="输入文本过滤结果"
|
||||
round
|
||||
style="min-width: 230px"
|
||||
/>
|
||||
<n-popconfirm
|
||||
placement="bottom"
|
||||
@positive-click="handleClearData"
|
||||
positive-text="确定"
|
||||
negative-text="取消"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button type="error" tertiary circle size="small">
|
||||
<template #icon>
|
||||
<ion-trash-outline />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
确认清空所有数据吗?
|
||||
</n-popconfirm>
|
||||
<n-upload
|
||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
:max="1"
|
||||
@change="handleImport"
|
||||
>
|
||||
<n-button type="primary" tertiary circle size="small">
|
||||
<n-button-group class="button-group">
|
||||
<n-popconfirm
|
||||
placement="bottom"
|
||||
@positive-click="handleClearData"
|
||||
positive-text="确定"
|
||||
negative-text="取消"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button type="default" ghost round size="small">
|
||||
<template #icon>
|
||||
<ion-trash-outline />
|
||||
</template>
|
||||
清空
|
||||
</n-button>
|
||||
</template>
|
||||
确认清空所有数据吗?
|
||||
</n-popconfirm>
|
||||
<n-button type="default" ghost round @click="fileDialog.open()" size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
</n-upload>
|
||||
<n-button type="primary" tertiary circle size="small" @click="handleExport">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-popover trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button type="primary" tertiary circle size="small">
|
||||
<template #icon>
|
||||
<ant-design-filter-outlined />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">筛选器</div>
|
||||
<n-form :model="filter" label-placement="left">
|
||||
<n-form-item v-for="item in filterFormItems" :label="item.label">
|
||||
<n-select
|
||||
v-if="item.type === 'select'"
|
||||
placeholder=""
|
||||
v-model:value="filter.keywords"
|
||||
clearable
|
||||
:options="item.params.options"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="filter-footer" @click="onFilterReset"><n-button>重置</n-button></div>
|
||||
</div>
|
||||
</n-popover>
|
||||
<n-button type="default" ghost round size="small" @click="handleExport">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
<n-popover trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button type="default" ghost round size="small">
|
||||
<template #icon>
|
||||
<ant-design-filter-outlined />
|
||||
</template>
|
||||
过滤
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">筛选器</div>
|
||||
<n-form :model="filter" label-placement="left">
|
||||
<n-form-item v-for="item in filterFormItems" :label="item.label">
|
||||
<n-select
|
||||
v-if="item.type === 'select'"
|
||||
placeholder=""
|
||||
v-model:value="filter.keywords"
|
||||
clearable
|
||||
:options="item.params.options"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="filter-footer" @click="onFilterReset"><n-button>重置</n-button></div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</n-button-group>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty v-if="allItems.length === 0" size="huge" style="padding-top: 40px">
|
||||
<n-empty v-if="allItems.length === 0" size="huge">
|
||||
<template #icon>
|
||||
<n-icon size="60">
|
||||
<solar-cat-linear />
|
||||
@ -324,7 +337,13 @@ const handleClearData = async () => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-content-container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
:deep(.n-card__content:has(.n-empty)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.filter-switch) {
|
||||
|
||||
21
src/composables/useLongTask.ts
Normal file
21
src/composables/useLongTask.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
startTask,
|
||||
};
|
||||
}
|
||||
@ -37,15 +37,16 @@ export async function exec<T, P extends Record<string, unknown>>(
|
||||
};
|
||||
return new Promise<T>(async (resolve, reject) => {
|
||||
setTimeout(() => reject('脚本运行超时'), timeout);
|
||||
const injectResults = await browser.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func,
|
||||
args: payload ? [payload] : undefined,
|
||||
});
|
||||
const ret = injectResults.pop();
|
||||
if (ret?.error) {
|
||||
reject(`注入脚本时发生错误: ${ret.error}`); // 似乎无法走到这一步
|
||||
try {
|
||||
const injectResults = await browser.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func,
|
||||
args: payload ? [payload] : undefined,
|
||||
});
|
||||
const ret = injectResults.pop();
|
||||
resolve(ret!.result as T);
|
||||
} catch (e) {
|
||||
throw new Error(`入脚本运行失败: ${e}`);
|
||||
}
|
||||
resolve(ret!.result as T);
|
||||
});
|
||||
}
|
||||
|
||||
1
src/logic/page-worker/types.d.ts
vendored
1
src/logic/page-worker/types.d.ts
vendored
@ -32,6 +32,7 @@ type AmazonReview = {
|
||||
rating: string;
|
||||
dateInfo: string;
|
||||
content: string;
|
||||
imageSrc: string[];
|
||||
};
|
||||
|
||||
type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
|
||||
|
||||
@ -3,7 +3,9 @@ import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-work
|
||||
|
||||
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
|
||||
|
||||
export const asinInputText = useWebExtensionStorage<string>('asinInputText', '');
|
||||
export const detailAsinInput = useWebExtensionStorage<string>('detailAsinInputText', '');
|
||||
|
||||
export const reviewAsinInput = useWebExtensionStorage<string>('reviewAsinInputText', '');
|
||||
|
||||
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
|
||||
|
||||
@ -17,8 +19,8 @@ export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>
|
||||
|
||||
export const allItems = computed({
|
||||
get() {
|
||||
const sItems = searchItems.value;
|
||||
const dItems = detailItems.value;
|
||||
const sItems = unref(searchItems);
|
||||
const dItems = unref(detailItems);
|
||||
return sItems.map<AmazonItem>((si) => {
|
||||
const asin = si.asin;
|
||||
const dItem = dItems.get(asin);
|
||||
@ -45,6 +47,8 @@ export const allItems = computed({
|
||||
});
|
||||
const detailItemsProps: (keyof AmazonDetailItem)[] = [
|
||||
'asin',
|
||||
'title',
|
||||
'price',
|
||||
'category1',
|
||||
'category2',
|
||||
'imageUrls',
|
||||
|
||||
@ -306,7 +306,12 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
const content = commentNode.querySelector<HTMLDivElement>(
|
||||
'[data-hook="review-body"]',
|
||||
)!.innerText;
|
||||
items.push({ id, username, title, rating, dateInfo, content });
|
||||
const imageSrc = Array.from(
|
||||
commentNode.querySelectorAll<HTMLImageElement>(
|
||||
'.review-image-tile-section img[src] img[src]',
|
||||
),
|
||||
).map((e) => e.getAttribute('src')!);
|
||||
items.push({ id, username, title, rating, dateInfo, content, imageSrc });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
@ -377,7 +382,10 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
||||
const content = commentNode.querySelector<HTMLDivElement>(
|
||||
'[data-hook="review-body"]',
|
||||
)!.innerText;
|
||||
items.push({ id, username, title, rating, dateInfo, content });
|
||||
const imageSrc = Array.from(
|
||||
commentNode.querySelectorAll<HTMLImageElement>('.review-image-tile-section img[src]'),
|
||||
).map((e) => e.getAttribute('src')!);
|
||||
items.push({ id, username, title, rating, dateInfo, content, imageSrc });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import fs from 'fs-extra';
|
||||
import type { Manifest } from 'webextension-polyfill';
|
||||
import { type Manifest } from 'webextension-polyfill';
|
||||
import type PkgType from '../package.json';
|
||||
import { isDev, isFirefox, port, r } from '../scripts/utils';
|
||||
|
||||
@ -33,7 +33,15 @@ export async function getManifest() {
|
||||
48: './assets/icon-512.png',
|
||||
128: './assets/icon-512.png',
|
||||
},
|
||||
permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting', 'unlimitedStorage'],
|
||||
permissions: [
|
||||
'tabs',
|
||||
'storage',
|
||||
'activeTab',
|
||||
'sidePanel',
|
||||
'scripting',
|
||||
'unlimitedStorage',
|
||||
'contextMenus',
|
||||
],
|
||||
host_permissions: ['*://*/*'],
|
||||
content_scripts: [
|
||||
{
|
||||
|
||||
@ -14,7 +14,8 @@ main {
|
||||
align-items: center;
|
||||
|
||||
.result-table {
|
||||
width: 95%;
|
||||
height: 90vh;
|
||||
width: 95vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
import SidePanel from './SidePanel.vue';
|
||||
import SidePanel from './Sidepanel.vue';
|
||||
|
||||
const theme: GlobalThemeOverrides = {
|
||||
common: {
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormItemRule, UploadOnChange } from 'naive-ui';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import pageWorker from '~/logic/page-worker';
|
||||
import { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
import { asinInputText, detailItems } from '~/logic/storage';
|
||||
import { detailAsinInput, detailItems } from '~/logic/storage';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const formItemRef = useTemplateRef('detail-form-item');
|
||||
const formItemRule: FormItemRule = {
|
||||
required: true,
|
||||
trigger: ['submit', 'blur'],
|
||||
message: '请输入格式正确的ASIN',
|
||||
validator: () => {
|
||||
return asinInputText.value.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
@ -25,7 +15,9 @@ const timelines = ref<
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const running = ref(false);
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
const asinInputRef = useTemplateRef('asin-input');
|
||||
|
||||
//#region Page Worker 初始化Code
|
||||
const worker = pageWorker.useAmazonPageWorker(); // 获取Page Worker单例
|
||||
@ -37,7 +29,7 @@ worker.channel.on('error', ({ message: msg }) => {
|
||||
content: msg,
|
||||
});
|
||||
message.error(msg);
|
||||
running.value = false;
|
||||
worker.stop();
|
||||
});
|
||||
worker.channel.on('item-base-info-collected', (ev) => {
|
||||
timelines.value.push({
|
||||
@ -98,72 +90,33 @@ const updateDetailItems = (row: { asin: string } & Partial<AmazonDetailItem>) =>
|
||||
};
|
||||
//#endregion
|
||||
|
||||
const handleImportAsin: UploadOnChange = ({ fileList }) => {
|
||||
if (fileList.length > 0) {
|
||||
const file = fileList.pop();
|
||||
if (file && file.file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result;
|
||||
if (typeof content === 'string') {
|
||||
asinInputText.value = content;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file.file, 'utf-8');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAsin = () => {
|
||||
const blob = new Blob([asinInputText.value], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = `asin-${new Date().toISOString()}.txt`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.info('导出完成');
|
||||
};
|
||||
|
||||
const handleFetchInfoFromPage = () => {
|
||||
const runTask = async () => {
|
||||
const asinList = asinInputText.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||
running.value = true;
|
||||
timelines.value = [
|
||||
{
|
||||
type: 'info',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
await worker.runDetaiPageTask(asinList, async (remains) => {
|
||||
asinInputText.value = remains.join('\n');
|
||||
});
|
||||
timelines.value.push({
|
||||
const task = async () => {
|
||||
const asinList = detailAsinInput.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||
timelines.value = [
|
||||
{
|
||||
type: 'info',
|
||||
title: '结束',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '数据采集完成',
|
||||
});
|
||||
running.value = false;
|
||||
};
|
||||
formItemRef.value?.validate({
|
||||
callback: (errors) => {
|
||||
if (errors) {
|
||||
return;
|
||||
} else {
|
||||
runTask();
|
||||
}
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
await worker.runDetaiPageTask(asinList, async (remains) => {
|
||||
detailAsinInput.value = remains.join('\n');
|
||||
});
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '结束',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '数据采集完成',
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
asinInputRef.value?.validate().then(async (success) => success && startTask(task));
|
||||
};
|
||||
|
||||
const handleInterrupt = () => {
|
||||
if (!running.value) return;
|
||||
if (!isRunning.value) return;
|
||||
worker.stop();
|
||||
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
|
||||
};
|
||||
@ -173,37 +126,8 @@ const handleInterrupt = () => {
|
||||
<div class="detail-page-entry">
|
||||
<header-title>Detail Page</header-title>
|
||||
<div class="interative-section">
|
||||
<n-space>
|
||||
<n-upload @change="handleImportAsin" accept=".txt" :max="1">
|
||||
<n-button :disabled="running" round size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
</n-upload>
|
||||
<n-button :disabled="running" @click="handleExportAsin" round size="small">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-form-item
|
||||
ref="detail-form-item"
|
||||
label-placement="left"
|
||||
:rule="formItemRule"
|
||||
style="padding-top: 0px"
|
||||
>
|
||||
<n-input
|
||||
:disabled="running"
|
||||
v-model:value="asinInputText"
|
||||
placeholder="输入ASINs"
|
||||
type="textarea"
|
||||
size="large"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button v-if="!running" round size="large" type="primary" @click="handleFetchInfoFromPage">
|
||||
<asins-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input" />
|
||||
<n-button v-if="!isRunning" round size="large" type="primary" @click="handleStart">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
</template>
|
||||
@ -216,7 +140,7 @@ const handleInterrupt = () => {
|
||||
停止
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="running" class="running-tip-section">
|
||||
<div v-if="isRunning" class="running-tip-section">
|
||||
<n-alert title="Warning" type="warning"> 警告,在插件运行期间请勿与浏览器交互。 </n-alert>
|
||||
</div>
|
||||
<progress-report class="progress-report" :timelines="timelines" />
|
||||
@ -230,29 +154,29 @@ const handleInterrupt = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.interative-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 85%;
|
||||
border-radius: 10px;
|
||||
border: 1px #00000020 dashed;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.interative-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 85%;
|
||||
border-radius: 10px;
|
||||
border: 1px #00000020 dashed;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.running-tip-section {
|
||||
margin: 10px 0 0 0;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
cursor: wait;
|
||||
}
|
||||
.running-tip-section {
|
||||
margin: 10px 0 0 0;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.progress-report {
|
||||
margin-top: 10px;
|
||||
width: 95%;
|
||||
}
|
||||
.progress-report {
|
||||
margin-top: 10px;
|
||||
width: 95%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,35 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import pageWorker from '~/logic/page-worker';
|
||||
import { reviewAsinInput } from '~/logic/storage';
|
||||
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
title: '错误发生',
|
||||
time: new Date().toLocaleString(),
|
||||
content: msg,
|
||||
});
|
||||
});
|
||||
worker.channel.on('item-review-collected', (ev) => {
|
||||
output.value = ev;
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `商品${ev.asin}评价`,
|
||||
time: new Date().toLocaleString(),
|
||||
content: `获取到 ${ev.reviews.length} 条评价`,
|
||||
});
|
||||
});
|
||||
|
||||
const inputText = ref('');
|
||||
const output = ref<any>(null);
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
const asinInputRef = useTemplateRef('asin-input');
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const task = async () => {
|
||||
const asinList = reviewAsinInput.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||
timelines.value = [
|
||||
{
|
||||
type: 'info',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
await worker.runReviewPageTask(asinList, async (remains) => {
|
||||
reviewAsinInput.value = remains.join('\n');
|
||||
});
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '结束',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '数据采集完成',
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
worker.runReviewPageTask(inputText.value.split('\n').filter((t) => /^[A-Z0-9]{10}/.exec(t)));
|
||||
asinInputRef.value?.validate().then(async (success) => success && startTask(task));
|
||||
};
|
||||
|
||||
const handleInterrupt = () => {
|
||||
worker.stop();
|
||||
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review-page-entry">
|
||||
<header-title>Review Page</header-title>
|
||||
<n-input type="textarea" v-model:value="inputText" />
|
||||
<n-button @click="handleStart">测试</n-button>
|
||||
<div>{{ output }}</div>
|
||||
<div class="interative-section">
|
||||
<asins-input v-model="reviewAsinInput" :disabled="isRunning" ref="asin-input" />
|
||||
<n-button v-if="!isRunning" round size="large" type="primary" @click="handleStart">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
</template>
|
||||
开始
|
||||
</n-button>
|
||||
<n-button v-else round size="large" type="primary" @click="handleInterrupt">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
</template>
|
||||
停止
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="isRunning" class="running-tip-section">
|
||||
<n-alert title="Warning" type="warning"> 警告,在插件运行期间请勿与浏览器交互。 </n-alert>
|
||||
</div>
|
||||
<progress-report class="progress-report" :timelines="timelines" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-page-entry {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.interative-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 85%;
|
||||
border-radius: 10px;
|
||||
border: 1px #00000020 dashed;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.running-tip-section {
|
||||
border-radius: 10px;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.progress-report {
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,8 +3,11 @@ import { keywordsList } from '~/logic/storage';
|
||||
import pageWorker from '~/logic/page-worker';
|
||||
import { NButton } from 'naive-ui';
|
||||
import { searchItems } from '~/logic/storage';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
|
||||
const message = useMessage();
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
//#region Initial Page Worker
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
@ -16,7 +19,6 @@ worker.channel.on('error', ({ message: msg }) => {
|
||||
});
|
||||
message.error(msg);
|
||||
worker.stop();
|
||||
running.value = false;
|
||||
});
|
||||
worker.channel.on('item-links-collected', ({ objs }) => {
|
||||
timelines.value.push({
|
||||
@ -28,7 +30,6 @@ worker.channel.on('item-links-collected', ({ objs }) => {
|
||||
searchItems.value = searchItems.value.concat(objs); // Add records
|
||||
});
|
||||
//#endregion
|
||||
const running = ref(false);
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
@ -39,12 +40,8 @@ const timelines = ref<
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const handleFetchInfoFromPage = async () => {
|
||||
if (keywordsList.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
const task = async () => {
|
||||
const kws = unref(keywordsList);
|
||||
running.value = true;
|
||||
timelines.value = [
|
||||
{
|
||||
type: 'info',
|
||||
@ -71,11 +68,17 @@ const handleFetchInfoFromPage = async () => {
|
||||
time: new Date().toLocaleString(),
|
||||
content: `搜索任务结束`,
|
||||
});
|
||||
running.value = false;
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (keywordsList.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
startTask(task);
|
||||
};
|
||||
|
||||
const handleInterrupt = () => {
|
||||
if (!running.value) return;
|
||||
if (!isRunning.value) return;
|
||||
worker.stop();
|
||||
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
|
||||
};
|
||||
@ -86,7 +89,7 @@ const handleInterrupt = () => {
|
||||
<header-title>Search Page</header-title>
|
||||
<div class="interactive-section">
|
||||
<n-dynamic-input
|
||||
:disabled="running"
|
||||
:disabled="isRunning"
|
||||
v-model:value="keywordsList"
|
||||
:min="1"
|
||||
:max="10"
|
||||
@ -96,7 +99,7 @@ const handleInterrupt = () => {
|
||||
round
|
||||
placeholder="请输入关键词采集信息"
|
||||
/>
|
||||
<n-button v-if="!running" type="primary" round size="large" @click="handleFetchInfoFromPage">
|
||||
<n-button v-if="!isRunning" type="primary" round size="large" @click="handleStart">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
@ -113,7 +116,7 @@ const handleInterrupt = () => {
|
||||
中断
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="running" class="running-tip-section">
|
||||
<div v-if="isRunning" class="running-tip-section">
|
||||
<n-alert title="Warning" type="warning"> 警告,在插件运行期间请勿与浏览器交互。 </n-alert>
|
||||
</div>
|
||||
<progress-report class="progress-report" :timelines="timelines" />
|
||||
|
||||
@ -1 +1 @@
|
||||
import './main.css';
|
||||
import './main.scss';
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('demo', () => {
|
||||
it('should work', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user