mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update
This commit is contained in:
parent
64b41f5841
commit
6645cd2b75
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "azon-seeker",
|
||||
"displayName": "Azon Seeker",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Starter modify by honestfox101",
|
||||
"scripts": {
|
||||
|
||||
@ -1,26 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
import { reviewItems } from '~/logic/storage';
|
||||
import ReviewPreview from './ReviewPreview.vue';
|
||||
|
||||
const props = defineProps<{ model: AmazonDetailItem }>();
|
||||
|
||||
const modal = useModal();
|
||||
const handleLoadMore = () => {
|
||||
const asin = props.model.asin;
|
||||
modal.create({
|
||||
title: `${asin}评论`,
|
||||
preset: 'card',
|
||||
style: {
|
||||
width: '80vw',
|
||||
height: '85vh',
|
||||
},
|
||||
content: () =>
|
||||
h(ReviewPreview, {
|
||||
asin,
|
||||
}),
|
||||
});
|
||||
};
|
||||
defineProps<{ model: AmazonDetailItem }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -50,25 +31,6 @@ const handleLoadMore = () => {
|
||||
<n-descriptions-item label="图片链接" :span="4">
|
||||
<image-link v-for="link in model.imageUrls" :url="link" />
|
||||
</n-descriptions-item>
|
||||
<!-- <n-descriptions-item
|
||||
v-if="model.topReviews && model.topReviews.length > 0"
|
||||
label="精选评论"
|
||||
:span="4"
|
||||
>
|
||||
<n-scrollbar style="max-height: 350px">
|
||||
<div class="review-item-cotent">
|
||||
<template v-for="review in model.topReviews">
|
||||
<review-card :model="review" />
|
||||
<div style="height: 7px"></div>
|
||||
</template>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
<div class="review-item-footer">
|
||||
<n-button :disabled="!reviewItems.has(model.asin)" @click="handleLoadMore" size="small">
|
||||
更多评论
|
||||
</n-button>
|
||||
</div>
|
||||
</n-descriptions-item> -->
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -2,12 +2,18 @@
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import type { FormItemRule } from 'naive-ui';
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
matchPattern?: RegExp;
|
||||
placeholder?: string;
|
||||
validateMessage?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
matchPattern: () => /^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g,
|
||||
placeholder: '输入ASINs',
|
||||
validateMessage: '请输入格式正确的ASIN',
|
||||
},
|
||||
);
|
||||
|
||||
@ -19,20 +25,20 @@ const formItemRef = useTemplateRef('detail-form-item');
|
||||
const formItemRule: FormItemRule = {
|
||||
required: true,
|
||||
trigger: ['submit', 'blur'],
|
||||
message: '请输入格式正确的ASIN',
|
||||
message: props.validateMessage,
|
||||
validator: () => {
|
||||
return modelValue.value.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null;
|
||||
return props.matchPattern.exec(modelValue.value) !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const fileDialog = useFileDialog({ accept: '.txt', multiple: false });
|
||||
fileDialog.onChange((fileList) => {
|
||||
const file = fileList?.item(0);
|
||||
file && handleImportAsin(file);
|
||||
file && handleImportIds(file);
|
||||
fileDialog.reset();
|
||||
});
|
||||
|
||||
const handleImportAsin = (file: File) => {
|
||||
const handleImportIds = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result;
|
||||
@ -43,10 +49,10 @@ const handleImportAsin = (file: File) => {
|
||||
reader.readAsText(file, 'utf-8');
|
||||
};
|
||||
|
||||
const handleExportAsin = () => {
|
||||
const handleExportIds = () => {
|
||||
const blob = new Blob([modelValue.value], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = `asin-${new Date().toISOString()}.txt`;
|
||||
const filename = `${new Date().toISOString()}.txt`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
@ -75,7 +81,7 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="asin-input">
|
||||
<div class="ids-input">
|
||||
<n-space>
|
||||
<n-button @click="fileDialog.open()" :disabled="disabled" round size="small">
|
||||
<template #icon>
|
||||
@ -83,7 +89,7 @@ defineExpose({
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
<n-button :disabled="disabled" @click="handleExportAsin" round size="small">
|
||||
<n-button :disabled="disabled" @click="handleExportIds" round size="small">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
@ -95,7 +101,7 @@ defineExpose({
|
||||
<n-input
|
||||
:disabled="disabled"
|
||||
v-model:value="modelValue"
|
||||
placeholder="输入ASINs"
|
||||
:placeholder="placeholder"
|
||||
type="textarea"
|
||||
size="large"
|
||||
/>
|
||||
@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
export type Timeline = {
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
timelines: {
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[];
|
||||
timelines: Timeline[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
||||
96
src/components/ResultTable.vue
Normal file
96
src/components/ResultTable.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
const page = reactive({ current: 1, size: 10 });
|
||||
|
||||
export type TableColumn =
|
||||
| {
|
||||
title: string;
|
||||
key: string;
|
||||
minWidth?: number;
|
||||
hidden?: boolean;
|
||||
render?: (row: any) => VNode;
|
||||
}
|
||||
| {
|
||||
type: 'expand';
|
||||
hidden?: boolean;
|
||||
expandable: (row: any) => boolean;
|
||||
renderExpand: (row: any) => VNode;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
records: Record<string, unknown>[];
|
||||
columns: TableColumn[];
|
||||
}>();
|
||||
|
||||
const itemView = computed(() => {
|
||||
const { current, size } = page;
|
||||
const data = props.records;
|
||||
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 };
|
||||
});
|
||||
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="result-table">
|
||||
<n-card class="result-content-container">
|
||||
<template #header><slot name="header" /></template>
|
||||
<template #header-extra><slot name="header-extra" /></template>
|
||||
<n-empty v-if="itemView.records.length === 0" size="huge">
|
||||
<template #icon>
|
||||
<n-icon size="60">
|
||||
<solar-cat-linear />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #default>
|
||||
<h3>还没有数据哦</h3>
|
||||
</template>
|
||||
</n-empty>
|
||||
<n-space vertical v-else>
|
||||
<n-data-table
|
||||
:row-key="() => `${generateUUID()}`"
|
||||
:columns="columns.filter((col) => col.hidden !== true)"
|
||||
:data="itemView.records"
|
||||
/>
|
||||
<div class="data-pagination">
|
||||
<n-pagination
|
||||
v-model:page="page.current"
|
||||
v-model:page-size="page.size"
|
||||
:page-count="itemView.pageCount"
|
||||
:page-sizes="[5, 10, 15, 20, 25]"
|
||||
show-size-picker
|
||||
/>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.result-content-container {
|
||||
min-height: 100%;
|
||||
:deep(.n-card__content:has(.n-empty)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.data-pagination {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
</style>
|
||||
@ -2,7 +2,7 @@
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { reviewItems } from '~/logic/storage';
|
||||
import { reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const props = defineProps<{ asin: string }>();
|
||||
|
||||
|
||||
@ -13,4 +13,4 @@ export function isForbiddenUrl(url: string): boolean {
|
||||
|
||||
export const isFirefox = navigator.userAgent.includes('Firefox');
|
||||
|
||||
export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '127.0.0.1:8000';
|
||||
export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '47.251.4.191:8000';
|
||||
|
||||
1
src/logic/page-worker/types.d.ts
vendored
1
src/logic/page-worker/types.d.ts
vendored
@ -121,7 +121,6 @@ type HomedepotDetailItem = {
|
||||
title: string;
|
||||
price: string;
|
||||
rate?: string;
|
||||
innerText: string;
|
||||
reviewCount?: number;
|
||||
mainImageUrl: string;
|
||||
modelInfo?: string;
|
||||
|
||||
@ -4,7 +4,7 @@ import type {
|
||||
AmazonItem,
|
||||
AmazonReview,
|
||||
AmazonSearchItem,
|
||||
} from './page-worker/types';
|
||||
} from '~/logic/page-worker/types';
|
||||
|
||||
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
|
||||
|
||||
3
src/logic/storages/global.ts
Normal file
3
src/logic/storages/global.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
|
||||
export const site = useWebExtensionStorage<'amazon' | 'homedepot'>('site', 'amazon');
|
||||
22
src/logic/storages/homedepot.ts
Normal file
22
src/logic/storages/homedepot.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
import { HomedepotDetailItem } from '../page-worker/types';
|
||||
|
||||
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
|
||||
'homedepot-details',
|
||||
new Map(),
|
||||
{
|
||||
listenToStorageChanges: 'options',
|
||||
},
|
||||
);
|
||||
|
||||
export const allItems = computed({
|
||||
get() {
|
||||
return Array.from(detailItems.value.values());
|
||||
},
|
||||
set(newValue) {
|
||||
detailItems.value = newValue.reduce((m, c) => {
|
||||
m.set(c.OSMID, c);
|
||||
return m;
|
||||
}, new Map());
|
||||
},
|
||||
});
|
||||
@ -226,17 +226,22 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
const script = document.evaluate(
|
||||
`//script[starts-with(text(), "\nP.when(\'A\').register")]`,
|
||||
let script = document.evaluate(
|
||||
`//script[starts-with(text(), "\nP.when(\'A\').register") or contains(text(), "\nP.when('A').register")]`,
|
||||
document,
|
||||
null,
|
||||
XPathResult.STRING_TYPE,
|
||||
).stringValue;
|
||||
const urls = [
|
||||
...script.matchAll(
|
||||
/(?<="hiRes":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g,
|
||||
),
|
||||
].map((e) => e[0]);
|
||||
const extractUrls = (pattern: RegExp) =>
|
||||
Array.from(script.matchAll(pattern)).map((e) => e[0]);
|
||||
let urls = extractUrls(
|
||||
/(?<="hiRes":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g,
|
||||
);
|
||||
if (urls.length === 0) {
|
||||
urls = extractUrls(
|
||||
/(?<="large":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g,
|
||||
);
|
||||
}
|
||||
return urls;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,12 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { site } from '~/logic/storages/global';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const options: { label: string; value: 'amazon' | 'homedepot' }[] = [
|
||||
{ label: 'Amazon', value: 'amazon' },
|
||||
{ label: 'Homedepot', value: 'homedepot' },
|
||||
];
|
||||
|
||||
watch(site, (newVal) => {
|
||||
router.push(`/${newVal}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<span></span>
|
||||
<h1 class="header-title">采集结果</h1>
|
||||
<span></span>
|
||||
<span>
|
||||
<n-popselect v-model:value="site" :options="options" placement="bottom-start">
|
||||
<n-button>
|
||||
<template #icon>
|
||||
<n-icon size="20">
|
||||
<garden-menu-fill-12 />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-popselect>
|
||||
</span>
|
||||
<span>
|
||||
<h1 class="header-title">采集结果</h1>
|
||||
</span>
|
||||
<span> </span>
|
||||
</header>
|
||||
<main>
|
||||
<router-view />
|
||||
@ -18,6 +43,8 @@ header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-title {
|
||||
cursor: default;
|
||||
}
|
||||
@ -28,9 +55,7 @@ main {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.result-table {
|
||||
height: 90vh;
|
||||
width: 95vw;
|
||||
}
|
||||
height: 90vh;
|
||||
width: 95vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
<script setup lang="tsx">
|
||||
import { NButton, NSpace } from 'naive-ui';
|
||||
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
|
||||
import type { TableColumn } from '~/components/ResultTable.vue';
|
||||
import { useCloudExporter } from '~/composables/useCloudExporter';
|
||||
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonItem, AmazonReview } from '~/logic/page-worker/types';
|
||||
import { allItems, reviewItems } from '~/logic/storage';
|
||||
import { allItems, reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const message = useMessage();
|
||||
const modal = useModal();
|
||||
const cloudExporter = useCloudExporter();
|
||||
|
||||
const page = reactive({ current: 1, size: 10 });
|
||||
|
||||
const defaultFilter = {
|
||||
keywords: undefined as string | undefined,
|
||||
search: '',
|
||||
@ -44,7 +42,7 @@ const onFilterReset = () => {
|
||||
Object.assign(filter, defaultFilter);
|
||||
};
|
||||
|
||||
const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
type: 'expand',
|
||||
expandable: (row) => row.hasDetail,
|
||||
@ -75,9 +73,6 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
render(row) {
|
||||
return <div>{row.title}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
@ -138,20 +133,6 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const itemView = computed<{ records: AmazonItem[]; pageCount: number; origin: AmazonItem[] }>(
|
||||
() => {
|
||||
const { current, size } = page;
|
||||
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 };
|
||||
},
|
||||
);
|
||||
|
||||
const extraHeaders: Header[] = [
|
||||
{ prop: 'link', label: '商品链接' },
|
||||
{
|
||||
@ -204,8 +185,9 @@ const getItemHeaders = () => {
|
||||
.concat(extraHeaders) as Header[];
|
||||
};
|
||||
|
||||
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
|
||||
const filteredData = computed(() => {
|
||||
const { search, detailOnly, keywords } = filter;
|
||||
let data = toRaw(allItems.value);
|
||||
if (search.trim() !== '') {
|
||||
data = data.filter((r) => {
|
||||
return [r.title, r.asin, r.keywords].some((field) =>
|
||||
@ -220,11 +202,11 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
|
||||
data = data.filter((r) => r.keywords === keywords);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
});
|
||||
|
||||
const handleLocalExport = async () => {
|
||||
const itemHeaders = getItemHeaders();
|
||||
const items = toRaw(itemView.value).origin;
|
||||
const items = toRaw(filteredData.value);
|
||||
const asins = new Set(items.map((e) => e.asin));
|
||||
const reviews = toRaw(reviewItems.value)
|
||||
.entries()
|
||||
@ -248,7 +230,7 @@ const handleCloudExport = async () => {
|
||||
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
|
||||
|
||||
const itemHeaders = getItemHeaders();
|
||||
const items = toRaw(itemView.value).origin;
|
||||
const items = toRaw(filteredData.value);
|
||||
const asins = new Set(items.map((e) => e.asin));
|
||||
const reviews = toRaw(reviewItems.value)
|
||||
.entries()
|
||||
@ -302,10 +284,10 @@ const handleClearData = async () => {
|
||||
|
||||
<template>
|
||||
<div class="result-table">
|
||||
<n-card class="result-content-container">
|
||||
<result-table :columns="columns" :records="filteredData">
|
||||
<template #header>
|
||||
<n-space>
|
||||
<div style="padding-right: 10px">结果数据表</div>
|
||||
<div style="padding-right: 10px">Amazon数据</div>
|
||||
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
|
||||
<template #checked> 详情 </template>
|
||||
<template #unchecked> 全部</template>
|
||||
@ -382,56 +364,15 @@ const handleClearData = async () => {
|
||||
</control-strip>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty v-if="itemView.records.length === 0" size="huge">
|
||||
<template #icon>
|
||||
<n-icon size="60">
|
||||
<solar-cat-linear />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #default>
|
||||
<h3>还没有数据哦</h3>
|
||||
</template>
|
||||
</n-empty>
|
||||
<n-space vertical v-else>
|
||||
<n-data-table
|
||||
:row-key="(row: any) => `${row.asin}-${~~(Math.random() * 10000)}`"
|
||||
:columns="columns.filter((col) => col.hidden !== true)"
|
||||
:data="itemView.records"
|
||||
/>
|
||||
<div class="data-pagination">
|
||||
<n-pagination
|
||||
v-model:page="page.current"
|
||||
v-model:page-size="page.size"
|
||||
:page-count="itemView.pageCount"
|
||||
:page-sizes="[5, 10, 15, 20, 25]"
|
||||
show-size-picker
|
||||
/>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</result-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-content-container {
|
||||
min-height: 100%;
|
||||
:deep(.n-card__content:has(.n-empty)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.filter-switch) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.data-pagination {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.exporter-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
220
src/options/views/HomedepotResultTable.vue
Normal file
220
src/options/views/HomedepotResultTable.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<script setup lang="tsx">
|
||||
import type { TableColumn } from '~/components/ResultTable.vue';
|
||||
import { useCloudExporter } from '~/composables/useCloudExporter';
|
||||
import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import { allItems } from '~/logic/storages/homedepot';
|
||||
|
||||
const message = useMessage();
|
||||
const cloudExporter = useCloudExporter();
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'OSMID',
|
||||
key: 'OSMID',
|
||||
},
|
||||
{
|
||||
title: '品牌名称',
|
||||
key: 'brandName',
|
||||
},
|
||||
{
|
||||
title: '型号信息',
|
||||
key: 'modelInfo',
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
key: 'price',
|
||||
},
|
||||
{
|
||||
title: '评分',
|
||||
key: 'rate',
|
||||
minWidth: 60,
|
||||
},
|
||||
{
|
||||
title: '评论数',
|
||||
key: 'reviewCount',
|
||||
minWidth: 75,
|
||||
},
|
||||
{
|
||||
title: '商品链接',
|
||||
key: 'link',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
title: '主图链接',
|
||||
key: 'mainImageUrl',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render(row: (typeof allItems.value)[0]) {
|
||||
return (
|
||||
<n-space>
|
||||
<n-button
|
||||
type="primary"
|
||||
text
|
||||
onClick={() => {
|
||||
browser.tabs.create({
|
||||
active: true,
|
||||
url: row.link,
|
||||
});
|
||||
}}
|
||||
>
|
||||
前往
|
||||
</n-button>
|
||||
</n-space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filteredData = computed(() => {
|
||||
return allItems.value;
|
||||
});
|
||||
|
||||
const getItemHeaders = () => {
|
||||
return columns
|
||||
.filter((col: Record<string, any>) => col.key !== 'actions')
|
||||
.reduce(
|
||||
(p, v: Record<string, any>) => {
|
||||
if ('key' in v && 'title' in v) {
|
||||
p.push({ label: v.title, prop: v.key });
|
||||
}
|
||||
return p;
|
||||
},
|
||||
[] as { label: string; prop: string }[],
|
||||
)
|
||||
.concat([]) as Header[];
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
allItems.value = [];
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
const itemHeaders = getItemHeaders();
|
||||
allItems.value = await importFromXLSX<(typeof allItems.value)[0]>(file, { headers: itemHeaders });
|
||||
message.info(`成功导入 ${file.name} 文件`);
|
||||
};
|
||||
|
||||
const handleLocalExport = async () => {
|
||||
const itemHeaders = getItemHeaders();
|
||||
await exportToXLSX(filteredData.value, { headers: itemHeaders });
|
||||
message.info(`导出完成`);
|
||||
};
|
||||
|
||||
const handleCloudExport = async () => {
|
||||
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
|
||||
const itemHeaders = getItemHeaders();
|
||||
const mappedData = await castRecordsByHeaders(filteredData.value, itemHeaders);
|
||||
const fragments = [{ data: mappedData, imageColumn: '主图链接' }];
|
||||
const filename = await cloudExporter.doExport(fragments);
|
||||
filename && message.info(`导出完成`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="result-table">
|
||||
<result-table :records="filteredData" :columns="columns">
|
||||
<template #header>
|
||||
<n-space>
|
||||
<div style="padding-right: 10px">Homedepot数据</div>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<control-strip round size="small" @clear="handleClearData" @import="handleImport">
|
||||
<template #exporter>
|
||||
<ul v-if="!cloudExporter.isRunning.value" class="exporter-menu">
|
||||
<li @click="handleLocalExport">
|
||||
<n-tooltip :delay="1000" placement="right">
|
||||
<template #trigger>
|
||||
<div class="menu-item">
|
||||
<n-icon><lucide-sheet /></n-icon>
|
||||
<span>本地导出</span>
|
||||
</div>
|
||||
</template>
|
||||
不包含图片
|
||||
</n-tooltip>
|
||||
</li>
|
||||
<li @click="handleCloudExport">
|
||||
<n-tooltip :delay="1000" placement="right">
|
||||
<template #trigger>
|
||||
<div class="menu-item">
|
||||
<n-icon><ic-outline-cloud /></n-icon>
|
||||
<span>云端导出</span>
|
||||
</div>
|
||||
</template>
|
||||
包含图片
|
||||
</n-tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="expoter-progress-panel">
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="(cloudExporter.progress.current * 100) / cloudExporter.progress.total"
|
||||
>
|
||||
<span>
|
||||
{{ cloudExporter.progress.current }}/{{ cloudExporter.progress.total }}
|
||||
</span>
|
||||
</n-progress>
|
||||
<n-button @click="cloudExporter.stop()">停止</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</control-strip>
|
||||
</template>
|
||||
</result-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exporter-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-size: 15px;
|
||||
|
||||
li {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
color: #222;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f6fa;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expoter-progress-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
cursor: wait;
|
||||
}
|
||||
</style>
|
||||
@ -1,14 +1,21 @@
|
||||
import { Plugin } from 'vue';
|
||||
import { createRouter, createMemoryHistory, RouteRecordRaw } from 'vue-router';
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createMemoryHistory,
|
||||
RouteRecordRaw,
|
||||
} from 'vue-router';
|
||||
import { useAppContext } from '~/composables/useAppContext';
|
||||
import { site } from '~/logic/storages/global';
|
||||
|
||||
const routeObj: Record<AppContext, RouteRecordRaw[]> = {
|
||||
options: [
|
||||
{ path: '/', redirect: '/amazon' },
|
||||
{ path: '/', redirect: `/${site.value}` },
|
||||
{ path: '/amazon', component: () => import('~/options/views/AmazonResultTable.vue') },
|
||||
{ path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') },
|
||||
],
|
||||
sidepanel: [
|
||||
{ path: '/', redirect: '/amazon' },
|
||||
{ path: '/', redirect: `/${site.value}` },
|
||||
{ path: '/amazon', component: () => import('~/sidepanel/views/AmazonSidepanel.vue') },
|
||||
{ path: '/homedepot', component: () => import('~/sidepanel/views/HomedepotSidepanel.vue') },
|
||||
],
|
||||
@ -19,7 +26,7 @@ export const router: Plugin = {
|
||||
const { appContext: context } = useAppContext();
|
||||
const routes = routeObj[context];
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
history: context === 'sidepanel' ? createMemoryHistory() : createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
app.use(router);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useCurrentUrl } from '~/composables/useCurrentUrl';
|
||||
import { site } from '~/logic/storages/global';
|
||||
|
||||
const theme: GlobalThemeOverrides = {
|
||||
common: {
|
||||
@ -21,11 +22,14 @@ watch(currentUrl, (newVal) => {
|
||||
switch (url.hostname) {
|
||||
case 'www.amazon.com':
|
||||
router.push('/amazon');
|
||||
site.value = 'amazon';
|
||||
break;
|
||||
case 'www.homedepot.com':
|
||||
router.push('/homedepot');
|
||||
site.value = 'homedepot';
|
||||
break;
|
||||
default:
|
||||
router.push(`/${site.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { amazon as pageWorker } from '~/logic/page-worker';
|
||||
import { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
import { detailAsinInput, detailItems } from '~/logic/storage';
|
||||
import { detailAsinInput, detailItems } from '~/logic/storages/amazon';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[]
|
||||
>([]);
|
||||
const timelines = ref<Timeline[]>([]);
|
||||
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
@ -133,9 +127,9 @@ const handleInterrupt = () => {
|
||||
|
||||
<template>
|
||||
<div class="detail-page-entry">
|
||||
<header-title>Detail Page</header-title>
|
||||
<header-title>Amazon Detail</header-title>
|
||||
<div class="interative-section">
|
||||
<asins-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input" />
|
||||
<ids-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 />
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { amazon as pageWorker } from '~/logic/page-worker';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { reviewAsinInput, reviewItems } from '~/logic/storage';
|
||||
import { reviewAsinInput, reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
@ -39,14 +40,7 @@ const asinInputRef = useTemplateRef('asin-input');
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[]
|
||||
>([]);
|
||||
const timelines = ref<Timeline[]>([]);
|
||||
|
||||
const task = async () => {
|
||||
const asinList = reviewAsinInput.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||
@ -93,9 +87,9 @@ const updateReviews = (params: { asin: string; reviews: AmazonReview[] }) => {
|
||||
|
||||
<template>
|
||||
<div class="review-page-entry">
|
||||
<header-title>Review Page</header-title>
|
||||
<header-title>Amazon Review</header-title>
|
||||
<div class="interative-section">
|
||||
<asins-input v-model="reviewAsinInput" :disabled="isRunning" ref="asin-input" />
|
||||
<ids-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 />
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { keywordsList } from '~/logic/storage';
|
||||
import { keywordsList } from '~/logic/storages/amazon';
|
||||
import { amazon as pageWorker } from '~/logic/page-worker';
|
||||
import { NButton } from 'naive-ui';
|
||||
import { searchItems } from '~/logic/storage';
|
||||
import { searchItems } from '~/logic/storages/amazon';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
@ -40,14 +41,7 @@ worker.channel.on('item-links-collected', ({ objs }) => {
|
||||
});
|
||||
//#endregion
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[]
|
||||
>([]);
|
||||
const timelines = ref<Timeline[]>([]);
|
||||
|
||||
const task = async () => {
|
||||
const kws = unref(keywordsList);
|
||||
@ -95,7 +89,7 @@ const handleInterrupt = () => {
|
||||
|
||||
<template>
|
||||
<div class="search-page-entry">
|
||||
<header-title>Search Page</header-title>
|
||||
<header-title>Amazon Search</header-title>
|
||||
<div class="interactive-section">
|
||||
<n-dynamic-input
|
||||
:disabled="isRunning"
|
||||
|
||||
@ -57,7 +57,7 @@ const running = ref(false);
|
||||
|
||||
<style scoped lang="scss">
|
||||
.side-panel {
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -1,25 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { homedepot } from '~/logic/page-worker';
|
||||
import { detailItems } from '~/logic/storages/homedepot';
|
||||
|
||||
const inputText = ref('');
|
||||
const output = ref(undefined);
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
|
||||
const worker = homedepot.useHomedepotWorker();
|
||||
worker.channel.on('detail-item-collected', ({ item }) => {
|
||||
output.value = item;
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `成功`,
|
||||
content: `成功获取到${item.OSMID}的商品信息`,
|
||||
time: new Date().toLocaleString(),
|
||||
});
|
||||
detailItems.value.set(item.OSMID, item);
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
worker.runDetailPageTask(inputText.value.split('\n').filter((id) => /\d+/.exec(id)));
|
||||
const timelines = ref<Timeline[]>([]);
|
||||
|
||||
const handleStart = () =>
|
||||
startTask(async () => {
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: `开始`,
|
||||
content: '任务开始',
|
||||
time: new Date().toLocaleString(),
|
||||
});
|
||||
await worker.runDetailPageTask(inputText.value.split('\n').filter((id) => /\d+/.exec(id)));
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: `结束`,
|
||||
content: '任务完成',
|
||||
time: new Date().toLocaleString(),
|
||||
});
|
||||
});
|
||||
|
||||
const handleInterrupt = () => {
|
||||
worker.stop();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="homedepot-sidepanel">
|
||||
<h3>Hello World!</h3>
|
||||
<n-input type="textarea" v-model:value="inputText" />
|
||||
<n-button @click="handleStart">Test!</n-button>
|
||||
<n-code word-wrap :code="JSON.stringify(output)" />
|
||||
<header-title>Homedepot</header-title>
|
||||
<div class="interative-section">
|
||||
<ids-input
|
||||
v-model="inputText"
|
||||
:disabled="isRunning"
|
||||
ref="asin-input"
|
||||
:match-pattern="/^\d+(\n\d+)*\n?$/g"
|
||||
placeholder="输入OSMID"
|
||||
validate-message="请输入格式正确的OSMID"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@ -28,5 +79,30 @@ const handleStart = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.progress-report {
|
||||
margin-top: 10px;
|
||||
width: 95%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,6 +3,9 @@ body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user