This commit is contained in:
johnathan 2025-06-04 17:22:47 +08:00
parent 5ac0f7ad64
commit 9f296e337b
10 changed files with 319 additions and 201 deletions

View File

@ -0,0 +1,70 @@
<script lang="ts" setup>
import { useFileDialog } from '@vueuse/core';
withDefaults(defineProps<{ size?: 'small' | 'medium' | 'large'; round?: boolean }>(), {
size: 'medium',
round: false,
});
const fileDialog = useFileDialog({
accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
multiple: false,
});
fileDialog.onChange((files) => {
const file = files?.item(0);
file && emit('import', file);
fileDialog.reset();
});
const emit = defineEmits<{
clear: [];
import: [file: File];
export: [];
}>();
</script>
<template>
<div class="control-strip">
<n-button-group class="button-group">
<n-popconfirm
placement="bottom"
@positive-click="emit('clear')"
positive-text="确定"
negative-text="取消"
>
<template #trigger>
<n-button type="default" ghost :round="round" :size="size">
<template #icon>
<ion-trash-outline />
</template>
清空
</n-button>
</template>
确认清空所有数据吗
</n-popconfirm>
<n-button type="default" ghost :round="round" @click="fileDialog.open()" :size="size">
<template #icon>
<gg-import />
</template>
导入
</n-button>
<n-button type="default" ghost :round="round" :size="size" @click="emit('export')">
<template #icon>
<ion-arrow-up-right-box-outline />
</template>
导出
</n-button>
<n-popover v-if="$slots.filter" trigger="hover" placement="bottom">
<template #trigger>
<n-button type="default" ghost :round="round" :size="size">
<template #icon>
<ant-design-filter-outlined />
</template>
过滤
</n-button>
</template>
<slot name="filter" />
</n-popover>
</n-button-group>
</div>
</template>

View File

@ -1,21 +1,21 @@
<script lang="ts" setup>
import type { AmazonDetailItem } from '~/logic/page-worker/types';
import { reviewItems } from '~/logic/storage';
import ReviewList from './ReviewList.vue';
import ReviewPreview from './ReviewPreview.vue';
const props = defineProps<{ model: AmazonDetailItem }>();
const modal = useModal();
const handleLoadMore = () => {
modal.create({
title: '评论',
title: `${props.model.asin}全部评论`,
preset: 'card',
style: {
width: '85vw',
width: '80vw',
height: '85vh',
},
content: () =>
h(ReviewList, {
h(ReviewPreview, {
asin: props.model.asin,
}),
});

View File

@ -5,20 +5,9 @@ import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types';
import { allItems } from '~/logic/storage';
import DetailDescription from './DetailDescription.vue';
import { useFileDialog } from '@vueuse/core';
const message = useMessage();
const fileDialog = useFileDialog({
accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
multiple: false,
});
fileDialog.onChange((files) => {
const file = files?.item(0);
file && handleImport(file);
fileDialog.reset();
});
const page = reactive({ current: 1, size: 10 });
const filter = reactive({
@ -162,12 +151,6 @@ const extraHeaders: Header[] = [
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
{
prop: 'topReviews',
label: '精选评论',
formatOutputValue: (val?: Record<string, any>[]) => JSON.stringify(val),
parseImportValue: (val?: string) => val && JSON.parse(val),
},
];
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
@ -248,44 +231,14 @@ const handleClearData = async () => {
round
style="min-width: 230px"
/>
<n-button-group class="button-group">
<n-popconfirm
placement="bottom"
@positive-click="handleClearData"
positive-text="确定"
negative-text="取消"
>
<template #trigger>
<n-button type="default" ghost round size="small">
<template #icon>
<ion-trash-outline />
</template>
清空
</n-button>
</template>
确认清空所有数据吗
</n-popconfirm>
<n-button type="default" ghost round @click="fileDialog.open()" size="small">
<template #icon>
<gg-import />
</template>
导入
</n-button>
<n-button type="default" ghost round size="small" @click="handleExport">
<template #icon>
<ion-arrow-up-right-box-outline />
</template>
导出
</n-button>
<n-popover trigger="hover" placement="bottom">
<template #trigger>
<n-button type="default" ghost round size="small">
<template #icon>
<ant-design-filter-outlined />
</template>
过滤
</n-button>
</template>
<control-strip
round
size="small"
@clear="handleClearData"
@export="handleExport"
@import="handleImport"
>
<template #filter>
<div class="filter-section">
<div class="filter-title">筛选器</div>
<n-form :model="filter" label-placement="left">
@ -301,11 +254,11 @@ const handleClearData = async () => {
</n-form>
<div class="filter-footer" @click="onFilterReset"><n-button>重置</n-button></div>
</div>
</n-popover>
</n-button-group>
</template>
</control-strip>
</n-space>
</template>
<n-empty v-if="allItems.length === 0" size="huge">
<n-empty v-if="itemView.records.length === 0" size="huge">
<template #icon>
<n-icon size="60">
<solar-cat-linear />
@ -356,7 +309,7 @@ const handleClearData = async () => {
}
.filter-section {
width: 250px;
min-width: 250px;
.filter-title {
font-size: 18px;

View File

@ -9,7 +9,7 @@ defineProps<{
<template>
<div class="review-card">
<h3 style="margin: 0">{{ model.username }}: {{ model.title }}</h3>
<n-rate readonly size="small" :default-value="Number(model.rating.split(' ')[0])" />
<n-rate readonly size="small" :value="Number(model.rating.split('.')[0])" />
<div v-for="paragraph in model.content.split('\n')">
{{ paragraph }}
</div>

View File

@ -1,120 +0,0 @@
<script lang="ts" setup>
import { useElementSize } from '@vueuse/core';
import type { AmazonReview } from '~/logic/page-worker/types';
import { reviewItems } from '~/logic/storage';
const props = defineProps<{ asin: string }>();
const containerRef = useTemplateRef('review-list');
const { height } = useElementSize(containerRef);
const allReviews = reviewItems.value.get(props.asin) || [];
const filter = reactive({
keywords: '',
});
const page = reactive({
current: 1,
pageSize: 5,
});
const view = computed(() => {
const offset = (page.current - 1) * page.pageSize;
const filteredData = filterData(allReviews);
const data = filteredData.slice(offset, offset + page.pageSize);
const pageCount = ~~(filteredData.length / page.pageSize);
return { data, pageCount, total: filteredData.length };
});
const filterData = (data: AmazonReview[]) => {
let filteredData = data;
if (filter.keywords) {
filteredData = data.filter((item) => {
const keywords = filter.keywords.toLowerCase();
return (
item.title.toLowerCase().includes(keywords) ||
item.content.toLowerCase().includes(keywords) ||
item.username.toLowerCase().includes(keywords)
);
});
}
return filteredData;
};
</script>
<template>
<div class="review-list" ref="review-list">
<div class="header">
<div class="header-section">
<n-space justify="start">
<n-input
v-model:value="filter.keywords"
style="width: 500px"
round
placeholder="输入关键词筛选评论"
/>
</n-space>
</div>
<div class="header-section"></div>
</div>
<n-scrollbar :style="{ maxHeight: `${height * 0.85}px`, minHeight: `${height * 0.85}px` }">
<div class="review-list-container" :style="{ minHeight: `${height * 0.8}px` }">
<template v-for="review in view.data">
<review-card :model="review" />
<div style="height: 7px" />
</template>
</div>
</n-scrollbar>
<div>
<n-space align="center">
<n-pagination
v-model:page="page.current"
v-model:page-size="page.pageSize"
:page-count="view.pageCount"
/>
<n-text>{{ view.total }}条评论</n-text>
</n-space>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-list {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: flex-start;
min-height: 100%;
max-height: 100%;
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.header-section {
display: flex;
flex-direction: row;
align-items: center;
max-width: 50%;
}
}
& > div:last-child {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}
.review-list-container {
border: 1px solid #e0e0e0;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: #fff;
padding: 16px;
}
</style>

View File

@ -0,0 +1,205 @@
<script lang="ts" setup>
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';
const props = defineProps<{ asin: string }>();
const message = useMessage();
const containerRef = useTemplateRef('review-list');
const { height } = useElementSize(containerRef);
const allReviews = reviewItems.value.get(props.asin) || [];
const filter = reactive({
keywords: '',
rating: null as string | null,
});
const page = reactive({
current: 1,
pageSize: 5,
});
const view = computed(() => {
const filteredData = filterData(allReviews);
const offset = (page.current - 1) * page.pageSize;
if (offset >= filteredData.length && page.current > 1) {
page.current = 1;
}
const data = filteredData.slice(offset, offset + page.pageSize);
const pageCount = ~~(filteredData.length / page.pageSize);
return { data, pageCount, total: filteredData.length };
});
const filterData = (data: AmazonReview[]) => {
let filteredData = data;
if (filter.keywords) {
filteredData = data.filter((item) => {
const keywords = filter.keywords.toLowerCase();
return (
item.title.toLowerCase().includes(keywords) ||
item.content.toLowerCase().includes(keywords) ||
item.username.toLowerCase().includes(keywords)
);
});
}
if (filter.rating) {
filteredData = filteredData.filter((item) => item.rating === filter.rating);
}
return filteredData;
};
const handleClearData = () => {
reviewItems.value.delete(props.asin);
allReviews.splice(0, allReviews.length);
page.current = 1;
};
const headers: Header[] = [
{ prop: 'username', label: '用户名' },
{ prop: 'title', label: '标题' },
{ prop: 'rating', label: '评分' },
{ prop: 'content', label: '内容' },
{ prop: 'dateInfo', label: '日期' },
{
prop: 'imageSrc',
label: '图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
];
const handleImport = async (file: File) => {
const importedData = await importFromXLSX<AmazonReview>(file, { headers });
if (importedData.length === 0) {
return;
}
const existingIds = new Set(allReviews.map((review) => review.id));
const newReviews = importedData.filter((review) => !existingIds.has(review.id));
if (newReviews.length > 0) {
allReviews.push(...newReviews);
allReviews.sort((a, b) => dayjs(b.dateInfo).valueOf() - dayjs(a.dateInfo).valueOf());
reviewItems.value.delete(props.asin); // Clear existing data for this ASIN
reviewItems.value.set(props.asin, allReviews);
page.current = 1; // Reset to first page after import
message.info(`成功导入 ${file.name} 文件 ${newReviews.length} 条新评论`);
}
};
const handleExport = () => {
exportToXLSX(allReviews, { headers });
message.info('导出完成');
};
</script>
<template>
<div class="review-list" ref="review-list">
<div class="header">
<div class="header-section">
<n-space justify="start">
<n-input
v-model:value="filter.keywords"
style="width: 500px"
placeholder="输入关键词筛选评论"
/>
</n-space>
</div>
<div class="header-section">
<n-space justify="end">
<n-select
v-model:value="filter.rating"
style="width: 200px"
round
placeholder="选择评分"
clearable
:options="
Array.from({ length: 5 }).map((_, i) => ({
label: `${i + 1}${'★'.repeat(i + 1)}`,
value: `${i + 1}.0 out of 5 stars`,
}))
"
/>
<control-strip @import="handleImport" @export="handleExport" @clear="handleClearData" />
</n-space>
</div>
</div>
<n-scrollbar :style="{ maxHeight: `${height * 0.85}px`, minHeight: `${height * 0.85}px` }">
<div class="review-list-container" :style="{ minHeight: `${height * 0.8}px` }">
<template v-if="view.data.length > 0" v-for="review in view.data">
<review-card :model="review" />
<div style="height: 7px" />
</template>
<template v-else>
<div class="empty-container">
<n-icon size="60">
<solar-cat-linear />
</n-icon>
<h3>还没有数据哦</h3>
</div>
</template>
</div>
</n-scrollbar>
<div>
<n-space align="center">
<n-pagination
v-model:page="page.current"
v-model:page-size="page.pageSize"
:page-count="view.pageCount"
/>
<n-text>{{ view.total }}条评论</n-text>
</n-space>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-list {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: flex-start;
min-height: 100%;
max-height: 100%;
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.header-section {
display: flex;
flex-direction: row;
align-items: center;
max-width: 50%;
}
}
& > div:last-child {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}
.review-list-container {
border: 1px solid #e0e0e0;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: #fff;
padding: 8px 16px;
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #888888ad;
}
</style>

View File

@ -156,20 +156,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
let rawRankingText: string | null = await injector.getRankText();
if (rawRankingText) {
const info: Pick<AmazonDetailItem, 'category1' | 'category2'> = {};
let statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)]+/.exec(rawRankingText)?.[0];
let statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)\-]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', '')) || null;
if (name && rank) {
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/\s\(See\sTop.+\)/, '');
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', ''));
if (name && !Number.isNaN(rank)) {
info['category1'] = { name, rank };
}
rawRankingText = rawRankingText.replace(statement, '');
}
statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)]+/.exec(rawRankingText)?.[0];
statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)\-]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', '')) || null;
if (name && rank) {
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '');
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', ''));
if (name && !Number.isNaN(rank)) {
info['category2'] = { name, rank };
}
}

View File

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

View File

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

View File

@ -100,7 +100,17 @@ export default defineConfig(({ command }) => ({
sidepanel: r('src/sidepanel/index.html'),
options: r('src/options/index.html'),
},
output: {},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('naive-ui')) return 'vendor-naive-ui';
else if (id.includes('vue')) return 'vendor-vue';
return 'vendor';
}
return null;
},
},
},
},
test: {