mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-02-05 14:53:34 +08:00
Update UI & Worker
This commit is contained in:
parent
ee8d6c1e0a
commit
6de45ebeb2
@ -39,6 +39,7 @@
|
|||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"crx": "^5.0.1",
|
"crx": "^5.0.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"emittery": "^1.1.0",
|
"emittery": "^1.1.0",
|
||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
|||||||
crx:
|
crx:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
emittery:
|
emittery:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
@ -2073,6 +2076,12 @@ packages:
|
|||||||
integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==,
|
integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dayjs@1.11.13:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==,
|
||||||
|
}
|
||||||
|
|
||||||
debounce@1.2.1:
|
debounce@1.2.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7364,6 +7373,8 @@ snapshots:
|
|||||||
|
|
||||||
date-fns@3.6.0: {}
|
date-fns@3.6.0: {}
|
||||||
|
|
||||||
|
dayjs@1.11.13: {}
|
||||||
|
|
||||||
debounce@1.2.1: {}
|
debounce@1.2.1: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
|
|||||||
40
src/components/DetailDescription.vue
Normal file
40
src/components/DetailDescription.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||||
|
|
||||||
|
const props = defineProps<{ model: AmazonDetailItem }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="detail-description">
|
||||||
|
<n-descriptions label-placement="left" bordered :column="4">
|
||||||
|
<n-descriptions-item label="ASIN" :span="2">
|
||||||
|
{{ props.model.asin }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="评价">
|
||||||
|
{{ props.model.rating || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="评论数">
|
||||||
|
{{ props.model.ratingCount || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="大类">
|
||||||
|
{{ props.model.category1?.name || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="排名">
|
||||||
|
{{ props.model.category1?.rank || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="小类">
|
||||||
|
{{ props.model.category2?.name || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="排名">
|
||||||
|
{{ props.model.category2?.rank || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="图片链接">
|
||||||
|
<div v-for="link in props.model.imageUrls">
|
||||||
|
{{ link }}
|
||||||
|
</div>
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
35
src/components/HeaderMenu.vue
Normal file
35
src/components/HeaderMenu.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const openOptionsPage = async () => {
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="header-menu">
|
||||||
|
<n-button class="setting-button" round @click="openOptionsPage" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="18" color="#0f0f0f">
|
||||||
|
<stash:search-results />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
<template #default> 数据 </template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.header-menu {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.setting-button {
|
||||||
|
margin-right: 20px;
|
||||||
|
opacity: 0.7;
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
src/components/ProgressReport.vue
Normal file
44
src/components/ProgressReport.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
timelines: {
|
||||||
|
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
time: string;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card class="progress-report" title="数据获取情况">
|
||||||
|
<n-timeline v-if="timelines.length > 0">
|
||||||
|
<n-timeline-item
|
||||||
|
v-for="(item, index) in timelines"
|
||||||
|
:key="index"
|
||||||
|
:type="item.type"
|
||||||
|
:title="item.title"
|
||||||
|
:time="item.time"
|
||||||
|
>
|
||||||
|
{{ item.content }}
|
||||||
|
</n-timeline-item>
|
||||||
|
</n-timeline>
|
||||||
|
<n-empty v-else size="large">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="50">
|
||||||
|
<solar-cat-linear />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
<template #default>还未开始</template>
|
||||||
|
</n-empty>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress-report {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,15 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, UploadOnChange } from 'naive-ui';
|
import { NButton, UploadOnChange } from 'naive-ui';
|
||||||
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
|
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
|
||||||
import { exportToXLSX, importFromXLSX } from '~/logic/data-io';
|
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||||
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
|
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types';
|
||||||
import { itemList as items } from '~/logic/storage';
|
import { allItems } from '~/logic/storage';
|
||||||
|
import DetailDescription from './DetailDescription.vue';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
const page = reactive({ current: 1, size: 10 });
|
const page = reactive({ current: 1, size: 10 });
|
||||||
const resultSearchText = ref('');
|
const filter = reactive({
|
||||||
const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
keywords: null as string | null,
|
||||||
|
search: '',
|
||||||
|
detailOnly: false,
|
||||||
|
});
|
||||||
|
const filterFormItems = computed(() => {
|
||||||
|
const records = allItems.value;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
prop: 'keywords',
|
||||||
|
label: '关键词',
|
||||||
|
type: 'select',
|
||||||
|
params: {
|
||||||
|
options: [
|
||||||
|
...records.reduce((o, c) => {
|
||||||
|
o.add(c.keywords);
|
||||||
|
return o;
|
||||||
|
}, new Set<string>()),
|
||||||
|
].map((opt) => ({
|
||||||
|
label: opt,
|
||||||
|
value: opt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const onFilterReset = () => {
|
||||||
|
Object.assign(filter, {
|
||||||
|
keywords: null as string | null,
|
||||||
|
search: '',
|
||||||
|
detailOnly: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||||
|
{
|
||||||
|
type: 'expand',
|
||||||
|
expandable: (row) => row.hasDetail,
|
||||||
|
renderExpand(row) {
|
||||||
|
return h(DetailDescription, { model: row as AmazonDetailItem }, () => '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关键词',
|
||||||
|
key: 'keywords',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '排位',
|
title: '排位',
|
||||||
key: 'rank',
|
key: 'rank',
|
||||||
@ -25,7 +71,7 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
|||||||
{
|
{
|
||||||
title: 'ASIN',
|
title: 'ASIN',
|
||||||
key: 'asin',
|
key: 'asin',
|
||||||
minWidth: 120,
|
minWidth: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '图片',
|
title: '图片',
|
||||||
@ -33,7 +79,12 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '链接',
|
title: '创建日期',
|
||||||
|
key: 'createTime',
|
||||||
|
minWidth: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
key: 'link',
|
key: 'link',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h(
|
return h(
|
||||||
@ -43,17 +94,10 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
|||||||
text: true,
|
text: true,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const tab = await browser.tabs
|
await browser.tabs.create({
|
||||||
.query({
|
active: true,
|
||||||
active: true,
|
url: row.link,
|
||||||
currentWindow: true,
|
});
|
||||||
})
|
|
||||||
.then((tabs) => tabs[0]);
|
|
||||||
if (tab) {
|
|
||||||
await browser.tabs.update(tab.id, {
|
|
||||||
url: row.link,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
() => '前往',
|
() => '前往',
|
||||||
@ -62,72 +106,125 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const itemView = computed(() => {
|
const itemView = computed<{ records: AmazonItem[]; pageCount: number }>(() => {
|
||||||
const { current, size } = page;
|
const { current, size } = page;
|
||||||
const searchText = resultSearchText.value;
|
let data = filterItemData(allItems.value); // Filter Data
|
||||||
let data = items.value;
|
|
||||||
if (searchText.trim() !== '') {
|
|
||||||
data = data.filter(
|
|
||||||
(r) =>
|
|
||||||
r.title.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
||||||
r.asin.toLowerCase().includes(searchText.toLowerCase()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let pageCount = ~~(data.length / size);
|
let pageCount = ~~(data.length / size);
|
||||||
pageCount += data.length % size > 0 ? 1 : 0;
|
pageCount += data.length % size > 0 ? 1 : 0;
|
||||||
data = data.slice((current - 1) * size, current * size);
|
data = data.slice((current - 1) * size, current * size);
|
||||||
return { data, pageCount };
|
return { records: data, pageCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extraHeaders: Header[] = [
|
||||||
|
{
|
||||||
|
prop: 'hasDetail',
|
||||||
|
label: '有详情',
|
||||||
|
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
|
||||||
|
parseImportValue: (val: string) => val === '是',
|
||||||
|
},
|
||||||
|
{ prop: 'rating', label: '评分' },
|
||||||
|
{ prop: 'ratingCount', label: '评论数' },
|
||||||
|
{ prop: 'category1.name', label: '大类' },
|
||||||
|
{ prop: 'category1.rank', label: '大类排行' },
|
||||||
|
{ prop: 'category2.name', label: '小类' },
|
||||||
|
{ prop: 'category2.rank', label: '小类排行' },
|
||||||
|
{
|
||||||
|
prop: 'imageUrls',
|
||||||
|
label: '商品图片链接',
|
||||||
|
formatOutputValue: (val?: string[]) => val?.join(';'),
|
||||||
|
parseImportValue: (val?: string) => val?.split(';'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
|
||||||
|
const { search, detailOnly, keywords } = filter;
|
||||||
|
if (search.trim() !== '') {
|
||||||
|
data = data.filter((r) => {
|
||||||
|
return [r.title, r.asin, r.keywords].some((field) =>
|
||||||
|
field.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (detailOnly) {
|
||||||
|
data = data.filter((r) => r.hasDetail);
|
||||||
|
}
|
||||||
|
if (keywords) {
|
||||||
|
data = data.filter((r) => r.keywords === keywords);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
const headers = columns.reduce(
|
const headers: Header[] = columns
|
||||||
(p, v: Record<string, any>) => {
|
.reduce(
|
||||||
if ('key' in v && 'title' in v) {
|
(p, v: Record<string, any>) => {
|
||||||
p.push({ label: v.title, prop: v.key });
|
if ('key' in v && 'title' in v) {
|
||||||
}
|
p.push({ label: v.title, prop: v.key });
|
||||||
return p;
|
}
|
||||||
},
|
return p;
|
||||||
[] as { label: string; prop: string }[],
|
},
|
||||||
);
|
[] as { label: string; prop: string }[],
|
||||||
exportToXLSX(items.value, { headers });
|
)
|
||||||
|
.concat(extraHeaders);
|
||||||
|
const data = filterItemData(allItems.value);
|
||||||
|
exportToXLSX(data, { headers });
|
||||||
message.info('导出完成');
|
message.info('导出完成');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport: UploadOnChange = async ({ fileList }) => {
|
const handleImport: UploadOnChange = async ({ fileList }) => {
|
||||||
if (fileList.length > 0) {
|
if (fileList.length !== 1 || fileList[0].file === null) {
|
||||||
const file = fileList.pop();
|
return;
|
||||||
if (file && file.file) {
|
|
||||||
const headers = columns.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 }[],
|
|
||||||
);
|
|
||||||
const importedData = await importFromXLSX<AmazonGoodsLinkItem>(file.file, { headers });
|
|
||||||
items.value = importedData; // 覆盖原数据
|
|
||||||
message.info(`成功导入 ${file?.file?.name} 文件 ${importedData.length} 条数据`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const file: File = fileList.pop()!.file!;
|
||||||
|
const headers: Header[] = columns
|
||||||
|
.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(extraHeaders);
|
||||||
|
const importedData = await importFromXLSX<AmazonItem>(file, { headers });
|
||||||
|
allItems.value = importedData; // 覆盖原数据
|
||||||
|
message.info(`成功导入 ${file.name} 文件 ${importedData.length} 条数据`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearData = async () => {
|
||||||
|
allItems.value = [];
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="result-table">
|
<div class="result-table">
|
||||||
<n-card class="result-content-container" title="结果框">
|
<n-card class="result-content-container">
|
||||||
<template #header-extra>
|
<template #header>
|
||||||
<n-space>
|
<n-space>
|
||||||
|
<div style="padding-right: 10px">结果框</div>
|
||||||
|
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
|
||||||
|
<template #checked> 详情 </template>
|
||||||
|
<template #unchecked> 全部</template>
|
||||||
|
</n-switch>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space size="small">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="resultSearchText"
|
v-model:value="filter.search"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="输入关键词查询结果"
|
placeholder="输入文本过滤结果"
|
||||||
round
|
round
|
||||||
/>
|
/>
|
||||||
<n-popconfirm @positive-click="items = []" positive-text="确定" negative-text="取消">
|
<n-popconfirm
|
||||||
|
placement="bottom"
|
||||||
|
@positive-click="handleClearData"
|
||||||
|
positive-text="确定"
|
||||||
|
negative-text="取消"
|
||||||
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button type="primary" tertiary round size="small">
|
<n-button type="error" tertiary circle size="small">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<ion-trash-outline />
|
<ion-trash-outline />
|
||||||
</template>
|
</template>
|
||||||
@ -137,22 +234,47 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
|||||||
</n-popconfirm>
|
</n-popconfirm>
|
||||||
<n-upload
|
<n-upload
|
||||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
:max="1"
|
||||||
@change="handleImport"
|
@change="handleImport"
|
||||||
>
|
>
|
||||||
<n-button type="primary" tertiary round size="small">
|
<n-button type="primary" tertiary circle size="small">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<gg-import />
|
<gg-import />
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-upload>
|
</n-upload>
|
||||||
<n-button type="primary" tertiary round size="small" @click="handleExport">
|
<n-button type="primary" tertiary circle size="small" @click="handleExport">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<ion-arrow-up-right-box-outline />
|
<ion-arrow-up-right-box-outline />
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</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-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
<n-empty v-if="items.length === 0" size="huge" style="padding-top: 40px">
|
<n-empty v-if="allItems.length === 0" size="huge" style="padding-top: 40px">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon size="60">
|
<n-icon size="60">
|
||||||
<solar-cat-linear />
|
<solar-cat-linear />
|
||||||
@ -164,16 +286,19 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
|||||||
</n-empty>
|
</n-empty>
|
||||||
<n-space vertical v-else>
|
<n-space vertical v-else>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
:row-key="(row: any) => `${row.asin}-${~~(Math.random() * 10000)}`"
|
||||||
:columns="columns.filter((col) => col.hidden !== true)"
|
:columns="columns.filter((col) => col.hidden !== true)"
|
||||||
:data="itemView.data"
|
:data="itemView.records"
|
||||||
/>
|
|
||||||
<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 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-space>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
@ -183,4 +308,27 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
|||||||
.result-content-container {
|
.result-content-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.filter-switch) {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
width: 250px;
|
||||||
|
|
||||||
|
.filter-title {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 5px 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
<template>
|
|
||||||
<p class="shared-subtitle">This is the {{ $app.context }} page</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.shared-subtitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,5 +1,45 @@
|
|||||||
import { utils, read, writeFileXLSX } from 'xlsx';
|
import { utils, read, writeFileXLSX } from 'xlsx';
|
||||||
|
|
||||||
|
function getAttribute<T extends unknown>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
): T | undefined {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let result: unknown = obj;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (result && typeof result === 'object' && key in result) {
|
||||||
|
result = (result as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAttribute(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current: Record<string, unknown> = obj;
|
||||||
|
|
||||||
|
for (const key of keys.slice(0, keys.length - 1)) {
|
||||||
|
if (!current[key] || typeof current[key] !== 'object') {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKey = keys[keys.length - 1];
|
||||||
|
current[finalKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Header = {
|
||||||
|
label: string;
|
||||||
|
prop: string;
|
||||||
|
parseImportValue?: (val: any) => any;
|
||||||
|
formatOutputValue?: (val: any) => any;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出为XLSX文件
|
* 导出为XLSX文件
|
||||||
* @param data 数据数组
|
* @param data 数据数组
|
||||||
@ -7,17 +47,28 @@ import { utils, read, writeFileXLSX } from 'xlsx';
|
|||||||
*/
|
*/
|
||||||
export function exportToXLSX(
|
export function exportToXLSX(
|
||||||
data: Record<string, unknown>[],
|
data: Record<string, unknown>[],
|
||||||
options: { fileName?: string; headers?: { label: string; prop: string }[] } = {},
|
options: {
|
||||||
|
fileName?: string;
|
||||||
|
headers?: Header[];
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
|
const headers: Header[] =
|
||||||
|
options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
|
||||||
const rows = data.map((item) => {
|
const rows = data.map((item) => {
|
||||||
const row: Record<string, unknown> = {};
|
const row: Record<string, unknown> = {};
|
||||||
headers.forEach((header) => {
|
headers.forEach((header) => {
|
||||||
row[header.label] = item[header.prop];
|
const value = getAttribute(item, header.prop);
|
||||||
|
if (header.formatOutputValue) {
|
||||||
|
row[header.label] = header.formatOutputValue(value);
|
||||||
|
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
|
||||||
|
row[header.label] = value;
|
||||||
|
} else {
|
||||||
|
row[header.label] = JSON.stringify(value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
@ -39,7 +90,7 @@ export function exportToXLSX(
|
|||||||
*/
|
*/
|
||||||
export async function importFromXLSX<T extends Record<string, unknown>>(
|
export async function importFromXLSX<T extends Record<string, unknown>>(
|
||||||
file: File,
|
file: File,
|
||||||
options: { headers?: { label: string; prop: string }[] } = {},
|
options: { headers?: Header[] } = {},
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -56,7 +107,10 @@ export async function importFromXLSX<T extends Record<string, unknown>>(
|
|||||||
jsonData = jsonData.map((item) => {
|
jsonData = jsonData.map((item) => {
|
||||||
const mappedItem: Record<string, unknown> = {};
|
const mappedItem: Record<string, unknown> = {};
|
||||||
options.headers?.forEach((header) => {
|
options.headers?.forEach((header) => {
|
||||||
mappedItem[header.prop] = item[header.label];
|
const value = header.parseImportValue
|
||||||
|
? header.parseImportValue(item[header.label])
|
||||||
|
: item[header.label];
|
||||||
|
setAttribute(mappedItem, header.prop, value);
|
||||||
});
|
});
|
||||||
return mappedItem as T;
|
return mappedItem as T;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,11 +36,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createNewTab(url: string): Promise<Tabs.Tab> {
|
||||||
|
const tab = await browser.tabs.create({
|
||||||
|
url,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
private async wanderSearchSinglePage(tab: Tabs.Tab) {
|
private async wanderSearchSinglePage(tab: Tabs.Tab) {
|
||||||
const tabId = tab.id!;
|
const tabId = tab.id!;
|
||||||
// #region Wait for the Next button to appear, indicating that the product items have finished loading
|
// #region Wait for the Next button to appear, indicating that the product items have finished loading
|
||||||
await exec(tabId, async () => {
|
await exec(tabId, async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
|
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
|
||||||
|
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||||
while (!document.querySelector('.s-pagination-strip')) {
|
while (!document.querySelector('.s-pagination-strip')) {
|
||||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||||
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
||||||
@ -159,15 +168,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
this._interruptSignal = true;
|
this._interruptSignal = true;
|
||||||
};
|
};
|
||||||
this.channel.on('error', stop);
|
this.channel.on('error', stop);
|
||||||
let result = {
|
let offset = 0;
|
||||||
hasNextPage: true,
|
while (!this._interruptSignal) {
|
||||||
data: [] as unknown[],
|
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
|
||||||
};
|
const objs = data.map((r, i) => ({
|
||||||
while (result.hasNextPage && !this._interruptSignal) {
|
...r,
|
||||||
result = await this.wanderSearchSinglePage(tab);
|
rank: offset + 1 + i,
|
||||||
this.channel.emit('item-links-collected', {
|
createTime: new Date().toLocaleString(),
|
||||||
objs: result.data as { link: string; title: string; imageSrc: string }[],
|
asin: /(?<=\/dp\/)[A-Z0-9]{10}/.exec(r.link as string)![0],
|
||||||
});
|
}));
|
||||||
|
this.channel.emit('item-links-collected', { objs });
|
||||||
|
offset += data.length;
|
||||||
|
if (!hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._interruptSignal = false;
|
this._interruptSignal = false;
|
||||||
this.channel.off('error', stop);
|
this.channel.off('error', stop);
|
||||||
@ -175,7 +189,6 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async wanderDetailPage(entry: string): Promise<void> {
|
public async wanderDetailPage(entry: string): Promise<void> {
|
||||||
const tab = await this.getCurrentTab();
|
|
||||||
const params = { asin: '', url: '' };
|
const params = { asin: '', url: '' };
|
||||||
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
|
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
|
||||||
const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!;
|
const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!;
|
||||||
@ -185,16 +198,12 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
params.asin = entry;
|
params.asin = entry;
|
||||||
params.url = `https://www.amazon.com/dp/${entry}`;
|
params.url = `https://www.amazon.com/dp/${entry}`;
|
||||||
}
|
}
|
||||||
if (!tab.url?.includes(`www.amazon.com`) || !tab.url?.includes(`/dp/${params.asin}`)) {
|
const tab = await this.createNewTab(params.url);
|
||||||
await browser.tabs.update(tab.id!, {
|
|
||||||
url: params.url,
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
//#region Await Production Introduction Element Loaded and Determine Page Pattern
|
//#region Await Production Introduction Element Loaded and Determine Page Pattern
|
||||||
const pattern = await exec(tab.id!, async () => {
|
const pattern = await exec(tab.id!, async () => {
|
||||||
|
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||||
let targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
let targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
||||||
while (!targetNode) {
|
while (!targetNode || document.readyState === 'loading') {
|
||||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||||
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
||||||
targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
||||||
@ -258,13 +267,17 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (rawRankingText) {
|
if (rawRankingText) {
|
||||||
const [category1Statement, category2Statement] = rawRankingText.split('\n');
|
let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!;
|
||||||
|
const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
|
||||||
const category1Ranking =
|
const category1Ranking =
|
||||||
Number(/(?<=#)[0-9,]+/.exec(category1Statement)?.[0].replace(',', '')) || null; // "," should be removed
|
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
|
||||||
const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(category1Statement)?.[0] || null;
|
|
||||||
|
rawRankingText = rawRankingText.replace(statement, '');
|
||||||
|
statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!;
|
||||||
|
const category2Name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
|
||||||
const category2Ranking =
|
const category2Ranking =
|
||||||
Number(/(?<=#)[0-9,]+/.exec(category2Statement)?.[0].replace(',', '')) || null; // "," should be removed
|
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
|
||||||
const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null;
|
|
||||||
this.channel.emit('item-category-rank-collected', {
|
this.channel.emit('item-category-rank-collected', {
|
||||||
asin: params.asin,
|
asin: params.asin,
|
||||||
category1: ![category1Name, category1Ranking].includes(null)
|
category1: ![category1Name, category1Ranking].includes(null)
|
||||||
@ -281,7 +294,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
let urls = [
|
let urls = [
|
||||||
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
|
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
|
||||||
].map((e) => e.src);
|
].map((e) => e.src);
|
||||||
// https://github.com/primedigitaltech/azon_seeker/issues/4
|
//#region process more images https://github.com/primedigitaltech/azon_seeker/issues/4
|
||||||
if (document.querySelector('.overlayRestOfImages')) {
|
if (document.querySelector('.overlayRestOfImages')) {
|
||||||
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages')!;
|
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages')!;
|
||||||
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
|
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
|
||||||
@ -298,14 +311,27 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
return url;
|
return url;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
//#endregion
|
||||||
|
//#region post-process image urls
|
||||||
|
urls = urls.map((rawUrl) => {
|
||||||
|
const imgUrl = new URL(rawUrl);
|
||||||
|
const paths = imgUrl.pathname.split('/');
|
||||||
|
const chunks = paths[paths.length - 1].split('.');
|
||||||
|
const [name, ext] = [chunks[0], chunks[chunks.length - 1]];
|
||||||
|
paths[paths.length - 1] = `${name}.${ext}`;
|
||||||
|
imgUrl.pathname = paths.join('/');
|
||||||
|
return imgUrl.toString();
|
||||||
|
});
|
||||||
|
//#endregion
|
||||||
return urls;
|
return urls;
|
||||||
});
|
});
|
||||||
imageUrls &&
|
imageUrls &&
|
||||||
this.channel.emit('item-images-collected', {
|
this.channel.emit('item-images-collected', {
|
||||||
asin: entry,
|
asin: params.asin,
|
||||||
urls: imageUrls,
|
imageUrls,
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
await browser.tabs.remove(tab.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
|
|||||||
34
src/logic/page-worker/types.d.ts
vendored
34
src/logic/page-worker/types.d.ts
vendored
@ -1,44 +1,46 @@
|
|||||||
import type Emittery from 'emittery';
|
import type Emittery from 'emittery';
|
||||||
|
|
||||||
type AmazonGoodsLinkItem = {
|
type AmazonSearchItem = {
|
||||||
|
keywords: string;
|
||||||
link: string;
|
link: string;
|
||||||
title: string;
|
title: string;
|
||||||
asin: string;
|
asin: string;
|
||||||
rank: number;
|
rank: number;
|
||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
|
createTime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AmazonDetailItem = {
|
||||||
|
asin: string;
|
||||||
|
rating?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
category1?: { name: string; rank: number };
|
||||||
|
category2?: { name: string; rank: number };
|
||||||
|
imageUrls: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||||
|
|
||||||
interface AmazonPageWorkerEvents {
|
interface AmazonPageWorkerEvents {
|
||||||
/**
|
/**
|
||||||
* The event is fired when worker collected links to items on the Amazon search page.
|
* The event is fired when worker collected links to items on the Amazon search page.
|
||||||
*/
|
*/
|
||||||
['item-links-collected']: { objs: { link: string; title: string; imageSrc: string }[] };
|
['item-links-collected']: { objs: Omit<AmazonSearchItem, 'keywords'>[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event is fired when worker collected goods' rating on the Amazon detail page.
|
* The event is fired when worker collected goods' rating on the Amazon detail page.
|
||||||
*/
|
*/
|
||||||
['item-rating-collected']: {
|
['item-rating-collected']: Pick<AmazonDetailItem, 'asin' | 'rating' | 'ratingCount'>;
|
||||||
asin: string;
|
|
||||||
rating: number;
|
|
||||||
ratingCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event is fired when worker
|
* The event is fired when worker
|
||||||
*/
|
*/
|
||||||
['item-category-rank-collected']: {
|
['item-category-rank-collected']: Pick<AmazonDetailItem, 'asin' | 'category1' | 'category2'>;
|
||||||
asin: string;
|
|
||||||
category1?: { name: string; rank: number };
|
|
||||||
category2?: { name: string; rank: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event is fired when images collected
|
* The event is fired when images collected
|
||||||
*/
|
*/
|
||||||
['item-images-collected']: {
|
['item-images-collected']: Pick<AmazonDetailItem, 'asin' | 'imageUrls'>;
|
||||||
asin: string;
|
|
||||||
urls: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error event that occurs when there is an issue with the Amazon page worker.
|
* Error event that occurs when there is an issue with the Amazon page worker.
|
||||||
|
|||||||
@ -1,6 +1,60 @@
|
|||||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||||
import type { AmazonGoodsLinkItem } from './page-worker/types';
|
import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types';
|
||||||
|
|
||||||
export const keywords = useWebExtensionStorage<string>('keywords', '');
|
export const keywords = useWebExtensionStorage<string>('keywords', '');
|
||||||
|
|
||||||
export const itemList = useWebExtensionStorage<AmazonGoodsLinkItem[]>('itemList', []);
|
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('itemList', []);
|
||||||
|
|
||||||
|
export const detailItems = useWebExtensionStorage<{ [asin: string]: AmazonDetailItem }>(
|
||||||
|
'detailItems',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const allItems = computed({
|
||||||
|
get() {
|
||||||
|
const sItems = searchItems.value;
|
||||||
|
const dItems = detailItems.value;
|
||||||
|
return sItems.map<AmazonItem>((si) => {
|
||||||
|
const asin = si.asin;
|
||||||
|
return asin in dItems
|
||||||
|
? { ...si, ...dItems[asin], hasDetail: true }
|
||||||
|
: { ...si, hasDetail: false };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
searchItems.value = newValue.map((row) => {
|
||||||
|
const props: (keyof AmazonSearchItem)[] = [
|
||||||
|
'keywords',
|
||||||
|
'asin',
|
||||||
|
'title',
|
||||||
|
'imageSrc',
|
||||||
|
'link',
|
||||||
|
'rank',
|
||||||
|
'createTime',
|
||||||
|
];
|
||||||
|
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||||
|
props.includes(key as keyof AmazonSearchItem),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||||
|
});
|
||||||
|
detailItems.value = newValue
|
||||||
|
.filter((row) => row.hasDetail)
|
||||||
|
.reduce<Record<string, AmazonDetailItem>>((o, row) => {
|
||||||
|
const { asin } = row;
|
||||||
|
const props: (keyof AmazonDetailItem)[] = [
|
||||||
|
'asin',
|
||||||
|
'category1',
|
||||||
|
'category2',
|
||||||
|
'imageUrls',
|
||||||
|
'rating',
|
||||||
|
'ratingCount',
|
||||||
|
];
|
||||||
|
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||||
|
props.includes(key as keyof AmazonDetailItem),
|
||||||
|
);
|
||||||
|
const item = Object.fromEntries(entries) as AmazonDetailItem;
|
||||||
|
o[asin] = item;
|
||||||
|
return o;
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -14,7 +14,7 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.result-table {
|
.result-table {
|
||||||
width: 70%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,39 +1,181 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FormRules, UploadOnChange } from 'naive-ui';
|
||||||
import pageWorkerFactory from '~/logic/page-worker';
|
import pageWorkerFactory from '~/logic/page-worker';
|
||||||
|
import { detailItems } from '~/logic/storage';
|
||||||
|
|
||||||
const inputText = ref('');
|
const message = useMessage();
|
||||||
const data = ref<Record<string, unknown>>({});
|
|
||||||
const worker = pageWorkerFactory.useAmazonPageWorker();
|
|
||||||
|
|
||||||
onMounted(() => {
|
const formItem = reactive({ asin: '' });
|
||||||
worker.channel.on('item-rating-collected', (ev) => {
|
const formRef = useTemplateRef('detail-form');
|
||||||
console.log('item-rating-collected', ev);
|
const formRules: FormRules = {
|
||||||
data.value = { ...data.value, ...ev };
|
asin: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
trigger: ['submit', 'blur'],
|
||||||
|
message: '请输入格式正确的ASIN',
|
||||||
|
validator: (_rule, val: string) => {
|
||||||
|
return (
|
||||||
|
typeof val === 'string' &&
|
||||||
|
val.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const timelines = ref<
|
||||||
|
{
|
||||||
|
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||||
|
title: string;
|
||||||
|
time: string;
|
||||||
|
content: string;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const worker = pageWorkerFactory.useAmazonPageWorker(); // 获取Page Worker单例
|
||||||
|
worker.channel.on('error', ({ message: msg }) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'error',
|
||||||
|
title: '错误',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: msg,
|
||||||
});
|
});
|
||||||
worker.channel.on('item-category-rank-collected', (ev) => {
|
message.error(msg);
|
||||||
console.log('item-category-rank-collected', ev);
|
});
|
||||||
data.value = { ...data.value, ...ev };
|
worker.channel.on('item-rating-collected', (ev) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'success',
|
||||||
|
title: `商品: ${ev.asin}`,
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
|
||||||
});
|
});
|
||||||
worker.channel.on('item-images-collected', (ev) => {
|
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||||
console.log('item-images-collected', ev);
|
});
|
||||||
data.value = { ...data.value, ...ev };
|
worker.channel.on('item-category-rank-collected', (ev) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'success',
|
||||||
|
title: `商品: ${ev.asin}`,
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: [
|
||||||
|
ev.category1 ? `#${ev.category1.rank} in ${ev.category1.name}` : '',
|
||||||
|
ev.category2 ? `#${ev.category2.rank} in ${ev.category2.name}` : '',
|
||||||
|
].join('\n'),
|
||||||
});
|
});
|
||||||
|
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||||
|
});
|
||||||
|
worker.channel.on('item-images-collected', (ev) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'success',
|
||||||
|
title: `商品: ${ev.asin}`,
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: `图片数: ${ev.imageUrls.length}`,
|
||||||
|
});
|
||||||
|
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGetInfo = async () => {
|
const handleImportAsin: UploadOnChange = ({ fileList }) => {
|
||||||
worker.wanderDetailPage(inputText.value);
|
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') {
|
||||||
|
formItem.asin = content;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file.file, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAsin = () => {
|
||||||
|
const blob = new Blob([formItem.asin], { 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 handleGetInfo = () => {
|
||||||
|
formRef.value?.validate(async (errors) => {
|
||||||
|
if (errors) {
|
||||||
|
message.error('格式错误,请检查输入');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asinList = formItem.asin.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||||
|
if (asinList.length > 0) {
|
||||||
|
timelines.value = [
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
title: '开始',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: '开始数据采集',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const asin of asinList) {
|
||||||
|
await worker.wanderDetailPage(asin);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'info',
|
||||||
|
title: '结束',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: '数据采集完成',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="detail-page-worker">
|
<div class="detail-page-worker">
|
||||||
<n-form>
|
<header-menu />
|
||||||
<n-form-item>
|
<div class="title">
|
||||||
<n-input v-model:value="inputText" />
|
<n-icon :size="60"> <mdi-cat style="color: black" /> </n-icon>
|
||||||
</n-form-item>
|
<h1 style="font-size: 30px; color: black">Detail Page</h1>
|
||||||
</n-form>
|
</div>
|
||||||
<n-button language="json" @click="handleGetInfo">Get Info</n-button>
|
<div class="interative-section">
|
||||||
<n-code>{{ data }}</n-code>
|
<n-space>
|
||||||
|
<n-upload @change="handleImportAsin" accept=".txt" :max="1">
|
||||||
|
<n-button round size="small">
|
||||||
|
<template #icon>
|
||||||
|
<gg-import />
|
||||||
|
</template>
|
||||||
|
导入
|
||||||
|
</n-button>
|
||||||
|
</n-upload>
|
||||||
|
<n-button @click="handleExportAsin" round size="small">
|
||||||
|
<template #icon>
|
||||||
|
<ion-arrow-up-right-box-outline />
|
||||||
|
</template>
|
||||||
|
导出
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
<n-form :rules="formRules" :model="formItem" ref="detail-form" label-placement="left">
|
||||||
|
<n-form-item style="padding-top: 0px" path="asin">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formItem.asin"
|
||||||
|
placeholder="输入ASINs"
|
||||||
|
type="textarea"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-button class="start-button" round size="large" type="primary" @click="handleGetInfo">
|
||||||
|
<template #icon>
|
||||||
|
<ant-design-thunderbolt-outlined />
|
||||||
|
</template>
|
||||||
|
开始
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<progress-report class="progress-report" :timelines="timelines" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -44,6 +186,30 @@ const handleGetInfo = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: #f0f0f0;
|
|
||||||
|
.title {
|
||||||
|
margin: 20px 0 30px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interative-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 85%;
|
||||||
|
padding: 15px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px #00000020 dashed;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-report {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,12 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { keywords } from '~/logic/storage';
|
import { keywords } from '~/logic/storage';
|
||||||
import pageWorker from '~/logic/page-worker';
|
import pageWorker from '~/logic/page-worker';
|
||||||
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
|
import type { AmazonSearchItem } from '~/logic/page-worker/types';
|
||||||
import { NButton } from 'naive-ui';
|
import { NButton } from 'naive-ui';
|
||||||
import { itemList as items } from '~/logic/storage';
|
import { searchItems } from '~/logic/storage';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
//#region Initial Page Worker
|
||||||
const worker = pageWorker.useAmazonPageWorker();
|
const worker = pageWorker.useAmazonPageWorker();
|
||||||
|
worker.channel.on('error', ({ message: msg }) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'error',
|
||||||
|
title: '错误',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: msg,
|
||||||
|
});
|
||||||
|
message.error(msg);
|
||||||
|
worker.stop();
|
||||||
|
});
|
||||||
|
worker.channel.on('item-links-collected', ({ objs }) => {
|
||||||
|
timelines.value.push({
|
||||||
|
type: 'success',
|
||||||
|
title: '检测到数据',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
content: `成功采集到 ${objs.length} 条数据`,
|
||||||
|
});
|
||||||
|
const addedRows = objs.map<AmazonSearchItem>((v) => {
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
keywords: keywords.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
searchItems.value = searchItems.value.concat(addedRows);
|
||||||
|
});
|
||||||
|
//#endregion
|
||||||
const workerRunning = ref(false);
|
const workerRunning = ref(false);
|
||||||
|
|
||||||
const timelines = ref<
|
const timelines = ref<
|
||||||
@ -18,20 +45,6 @@ const timelines = ref<
|
|||||||
}[]
|
}[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const onItemLinksCollected = (ev: { objs: Record<string, unknown>[] }) => {
|
|
||||||
timelines.value.push({
|
|
||||||
type: 'success',
|
|
||||||
title: '检测到数据',
|
|
||||||
time: new Date().toLocaleString(),
|
|
||||||
content: `成功采集到 ${ev.objs.length} 条数据`,
|
|
||||||
});
|
|
||||||
const addedRows = ev.objs.map((v, i) => {
|
|
||||||
const [asin] = /(?<=\/dp\/)[A-Z0-9]{10}/.exec(v.link as string)!;
|
|
||||||
return { ...v, asin, rank: items.value.length + i + 1 } as AmazonGoodsLinkItem;
|
|
||||||
});
|
|
||||||
items.value = items.value.concat(addedRows);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCollectStart = async () => {
|
const onCollectStart = async () => {
|
||||||
workerRunning.value = true;
|
workerRunning.value = true;
|
||||||
timelines.value = [
|
timelines.value = [
|
||||||
@ -45,19 +58,10 @@ const onCollectStart = async () => {
|
|||||||
if (keywords.value.trim() === '') {
|
if (keywords.value.trim() === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
worker.channel.on('error', ({ message: msg }) => {
|
//#region start page worker
|
||||||
timelines.value.push({
|
|
||||||
type: 'error',
|
|
||||||
title: '错误',
|
|
||||||
time: new Date().toLocaleString(),
|
|
||||||
content: msg,
|
|
||||||
});
|
|
||||||
message.error(msg);
|
|
||||||
});
|
|
||||||
worker.channel.on('item-links-collected', onItemLinksCollected);
|
|
||||||
await worker.doSearch(keywords.value);
|
await worker.doSearch(keywords.value);
|
||||||
await worker.wanderSearchPage();
|
await worker.wanderSearchPage();
|
||||||
worker.channel.off('item-links-collected', onItemLinksCollected);
|
//#endregion
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: '结束',
|
title: '结束',
|
||||||
@ -69,83 +73,54 @@ const onCollectStart = async () => {
|
|||||||
|
|
||||||
const onCollectStop = async () => {
|
const onCollectStop = async () => {
|
||||||
workerRunning.value = false;
|
workerRunning.value = false;
|
||||||
|
worker.stop();
|
||||||
message.info('停止收集');
|
message.info('停止收集');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openOptionsPage = async () => {
|
|
||||||
await browser.runtime.openOptionsPage();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="search-page-worker">
|
<main class="search-page-worker">
|
||||||
<div class="header-menu">
|
<header-menu />
|
||||||
<n-button :disabled="workerRunning" class="setting-button" round @click="openOptionsPage">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon size="20" color="#0f0f0f">
|
|
||||||
<stash:search-results />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
<template #default> 数据 </template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<n-space class="app-title">
|
<n-space class="app-title">
|
||||||
<mdi-cat style="font-size: 60px; color: black" />
|
<mdi-cat style="font-size: 60px; color: black" />
|
||||||
<h1>Azon Seeker</h1>
|
<h1>Search Page</h1>
|
||||||
</n-space>
|
</n-space>
|
||||||
<n-space>
|
<div class="interactive-section">
|
||||||
<n-input
|
<n-space>
|
||||||
v-model:value="keywords"
|
<n-input
|
||||||
class="search-input-box"
|
:disabled="workerRunning"
|
||||||
autosize
|
v-model:value="keywords"
|
||||||
size="large"
|
class="search-input-box"
|
||||||
round
|
autosize
|
||||||
placeholder="请输入关键词采集信息"
|
size="large"
|
||||||
>
|
round
|
||||||
<template #prefix>
|
placeholder="请输入关键词采集信息"
|
||||||
<n-icon size="20">
|
|
||||||
<ion-search />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-input>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
round
|
|
||||||
size="large"
|
|
||||||
@click="!workerRunning ? onCollectStart() : onCollectStop()"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<n-icon v-if="!workerRunning" size="20">
|
|
||||||
<ant-design-thunderbolt-outlined />
|
|
||||||
</n-icon>
|
|
||||||
<n-icon v-else size="20">
|
|
||||||
<ion-stop-outline />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<n-card class="progress-report" title="数据获取情况">
|
|
||||||
<n-timeline v-if="timelines.length > 0">
|
|
||||||
<n-timeline-item
|
|
||||||
v-for="(item, index) in timelines"
|
|
||||||
:key="index"
|
|
||||||
:type="item.type"
|
|
||||||
:title="item.title"
|
|
||||||
:time="item.time"
|
|
||||||
>
|
>
|
||||||
{{ item.content }}
|
<template #prefix>
|
||||||
</n-timeline-item>
|
<n-icon size="20">
|
||||||
</n-timeline>
|
<ion-search />
|
||||||
<n-empty v-else size="large">
|
</n-icon>
|
||||||
<template #icon>
|
</template>
|
||||||
<n-icon size="50">
|
</n-input>
|
||||||
<solar-cat-linear />
|
<n-button
|
||||||
</n-icon>
|
type="primary"
|
||||||
</template>
|
round
|
||||||
<template #default>还未开始</template>
|
size="large"
|
||||||
</n-empty>
|
@click="!workerRunning ? onCollectStart() : onCollectStop()"
|
||||||
</n-card>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon v-if="!workerRunning" size="20">
|
||||||
|
<ant-design-thunderbolt-outlined />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else size="20">
|
||||||
|
<ion-stop-outline />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<progress-report class="progress-report" :timelines="timelines" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -158,30 +133,22 @@ const openOptionsPage = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
.header-menu {
|
|
||||||
width: 95%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
.setting-button {
|
|
||||||
opacity: 0.7;
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
margin-top: 60px;
|
margin-top: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input-box {
|
.interactive-section {
|
||||||
min-width: 240px;
|
padding: 10px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px #00000020 dashed;
|
||||||
|
|
||||||
|
.search-input-box {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-report {
|
.progress-report {
|
||||||
width: 95%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -23,12 +23,24 @@ const showHeader = ref(false);
|
|||||||
<template>
|
<template>
|
||||||
<div class="side-panel">
|
<div class="side-panel">
|
||||||
<div class="header-menu" v-if="showHeader">
|
<div class="header-menu" v-if="showHeader">
|
||||||
<n-tabs placement="top" :default-value="tabs[0].name" type="card" v-model:value="selectedTab">
|
<n-tabs
|
||||||
|
placement="top"
|
||||||
|
:default-value="tabs[0].name"
|
||||||
|
type="segment"
|
||||||
|
:value="selectedTab"
|
||||||
|
@update:value="
|
||||||
|
(val) => {
|
||||||
|
if (tabs.findIndex((t) => t.name === val) !== -1) {
|
||||||
|
selectedTab = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
<n-tab v-for="tab in tabs" :name="tab.name" />
|
<n-tab v-for="tab in tabs" :name="tab.name" />
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="display-header-button" @click="showHeader = !showHeader">
|
<div class="display-header-button" @click="showHeader = !showHeader">
|
||||||
<n-icon size="22">
|
<n-icon size="18">
|
||||||
<ion-chevron-up v-if="showHeader" />
|
<ion-chevron-up v-if="showHeader" />
|
||||||
<ion-chevron-down v-else />
|
<ion-chevron-down v-else />
|
||||||
</n-icon>
|
</n-icon>
|
||||||
@ -66,7 +78,7 @@ const showHeader = ref(false);
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
> .n-icon {
|
> .n-icon {
|
||||||
opacity: 0.45;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user