diff --git a/components/modal/ArticleTaskDetail.vue b/components/modal/ArticleTaskDetail.vue new file mode 100644 index 00000000..096b9fa3 --- /dev/null +++ b/components/modal/ArticleTaskDetail.vue @@ -0,0 +1,88 @@ + + + + + + + + + {{ title }} + + {{ description }} + + + + + + + + + 共 {{ items.length }} 篇,当前展示 {{ visibleItems.length }} 篇 + + + + 暂无明细 + + + {{ index + 1 }}. {{ item.title }} + 发布时间:{{ item.publishTime }} + + + + + + + + + + + + 我知道了 + + + + + diff --git a/composables/useDownloader.ts b/composables/useDownloader.ts index 3744a3f9..801859ef 100644 --- a/composables/useDownloader.ts +++ b/composables/useDownloader.ts @@ -4,6 +4,8 @@ import type { Metadata } from '~/store/v2/metadata'; import { Downloader } from '~/utils/download/Downloader'; import type { DownloaderStatus } from '~/utils/download/types'; +type DownloadTaskType = 'html' | 'metadata' | 'comment' | 'fakeid'; + export interface DownloadArticleOptions { // 文章内容下载成功回调 onContent: (url: string) => void; @@ -22,6 +24,9 @@ export interface DownloadArticleOptions { // 修复单篇文章下载的 fakeid 专用 onFakeID: (url: string, fakeid: string) => void; + + // 抓取任务结束回调(用于展示失败明细) + onFinish: (type: DownloadTaskType, status: DownloaderStatus) => void; } export default (options: Partial = {}) => { @@ -75,6 +80,9 @@ export default (options: Partial = {}) => { '【文章内容】抓取完成', `本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}, 检测到已被删除:${status.deleted.length}` ); + if (typeof options.onFinish === 'function') { + options.onFinish('html', status); + } }); downloader.on('download:stop', () => { toast.info('HTML下载任务已停止'); @@ -134,6 +142,9 @@ export default (options: Partial = {}) => { '【阅读量】抓取完成', `本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}, 检测到已被删除:${status.deleted.length}` ); + if (typeof options.onFinish === 'function') { + options.onFinish('metadata', status); + } }); await downloader.startDownload('metadata'); @@ -178,6 +189,9 @@ export default (options: Partial = {}) => { '【留言内容】抓取完成', `本次抓取耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}` ); + if (typeof options.onFinish === 'function') { + options.onFinish('comment', status); + } }); await downloader.startDownload('comments'); @@ -225,6 +239,9 @@ export default (options: Partial = {}) => { '【fakeid】修复完成', `本次耗时 ${formatElapsedTime(seconds)}, 成功:${status.completed.length}, 失败:${status.failed.length}` ); + if (typeof options.onFinish === 'function') { + options.onFinish('fakeid', status); + } }); await downloader.startDownload('fakeid'); @@ -237,7 +254,7 @@ export default (options: Partial = {}) => { } } - async function download(type: 'html' | 'metadata' | 'comment' | 'fakeid', urls: string[]) { + async function download(type: DownloadTaskType, urls: string[]) { if (type === 'html') { await downloadArticleHTML(urls); } else if (type === 'metadata') { diff --git a/composables/useExporter.ts b/composables/useExporter.ts index 595c7f41..a5bfb6f0 100644 --- a/composables/useExporter.ts +++ b/composables/useExporter.ts @@ -3,7 +3,11 @@ import toastFactory from '~/composables/toast'; import { Exporter } from '~/utils/download/Exporter'; import type { ExporterStatus } from '~/utils/download/types'; -export default () => { +export interface UseExporterOptions { + onContentMissing: (urls: string[]) => void; +} + +export default (options: Partial = {}) => { const toast = toastFactory(); const loading = ref(false); @@ -287,10 +291,18 @@ export default () => { function exportFile( type: 'excel' | 'json' | 'html' | 'text' | 'markdown' | 'word' | 'pdf', urls: string[], - contentNotDownloadedCount?: number, + contentNotDownloaded: number | string[] = [], ) { - if (needsContentFormats.has(type) && contentNotDownloadedCount) { + const contentNotDownloadedUrls = Array.isArray(contentNotDownloaded) ? contentNotDownloaded : []; + const contentNotDownloadedCount = Array.isArray(contentNotDownloaded) + ? contentNotDownloaded.length + : contentNotDownloaded; + + if (needsContentFormats.has(type) && contentNotDownloadedCount > 0) { toast.warning('提示', `有 ${contentNotDownloadedCount} 篇文章尚未抓取内容,请先抓取内容后再导出`); + if (contentNotDownloadedUrls.length > 0 && typeof options.onContentMissing === 'function') { + options.onContentMissing(contentNotDownloadedUrls); + } return; } diff --git a/pages/dashboard/article.vue b/pages/dashboard/article.vue index 6bb7a9f2..2585866f 100644 --- a/pages/dashboard/article.vue +++ b/pages/dashboard/article.vue @@ -20,6 +20,7 @@ import GridAlbum from '~/components/grid/Album.vue'; import GridArticleActions from '~/components/grid/ArticleActions.vue'; import GridCoverTooltip from '~/components/grid/CoverTooltip.vue'; import GridStatusBar from '~/components/grid/StatusBar.vue'; +import ArticleTaskDetailModal from '~/components/modal/ArticleTaskDetail.vue'; import AccountSelectorForArticle from '~/components/selector/AccountSelectorForArticle.vue'; import { isDev, websiteName } from '~/config'; import { sharedGridOptions } from '~/config/shared-grid-options'; @@ -31,7 +32,7 @@ import { type MpAccount } from '~/store/v2/info'; import { getMetadataCache, type Metadata } from '~/store/v2/metadata'; import type { Preferences } from '~/types/preferences'; import type { AppMsgExWithFakeID } from '~/types/types'; -import type { ArticleMetadata } from '~/utils/download/types'; +import type { ArticleMetadata, DownloaderStatus } from '~/utils/download/types'; import { createBooleanColumnFilterParams, createDateColumnFilterParams } from '~/utils/grid'; useHead({ @@ -348,6 +349,7 @@ function onFilterChanged(event: FilterChangedEvent) { } const preferences = usePreferences(); +const modal = useModal(); const hideDeleted = computed(() => (preferences.value as unknown as Preferences).hideDeleted); const previewArticleRef = ref(null); @@ -408,10 +410,71 @@ function onSelectionChanged(event: SelectionChangedEvent) { const selectedArticleUrls = computed(() => { return selectedArticles.value.map(article => article.link); }); -const contentNotDownloadedCount = computed(() => { - return selectedArticles.value.filter(article => !article.contentDownload).length; +const contentNotDownloadedUrls = computed(() => { + return selectedArticles.value.filter(article => !article.contentDownload).map(article => article.link); }); +type TaskDetailItem = { + title: string; + publishTime: string; + url: string; +}; + +function findArticleByUrl(url: string): Article | undefined { + return selectedArticles.value.find(article => article.link === url) || globalRowData.find(article => article.link === url); +} + +function formatArticlePublishTime(article?: Article): string { + if (!article) { + return '--'; + } + const ts = article.update_time || article.create_time; + if (!ts) { + return '--'; + } + return formatTimeStamp(ts); +} + +function buildTaskDetailItems(urls: string[]): TaskDetailItem[] { + return urls.map(url => { + const article = findArticleByUrl(url); + return { + title: article?.title || url, + publishTime: formatArticlePublishTime(article), + url, + }; + }); +} + +function openTaskDetailModal(title: string, description: string, urls: string[]) { + const items = buildTaskDetailItems(urls); + modal.open(ArticleTaskDetailModal, { + title, + description, + items, + collapsedCount: 10, + }); +} + +function handleDownloadFinish(type: 'html' | 'metadata' | 'comment' | 'fakeid', status: DownloaderStatus) { + if (status.failed.length === 0) { + return; + } + + const titleMap = { + html: '文章内容抓取失败明细', + metadata: '阅读量抓取失败明细', + comment: '留言抓取失败明细', + fakeid: 'fakeid 修复失败明细', + } as const; + + openTaskDetailModal( + titleMap[type], + `共 ${status.failed.length} 篇抓取失败。默认展示前 10 条,可展开查看全部。`, + status.failed + ); +} + const { loading: downloadBtnLoading, completed_count: downloadCompletedCount, @@ -489,6 +552,9 @@ const { console.warn(`${url} not found in table data when update commentDownload`); } }, + onFinish(type, status) { + handleDownloadFinish(type, status); + }, }); const { @@ -497,7 +563,11 @@ const { completed_count: exportCompletedCount, total_count: exportTotalCount, exportFile, -} = useExporter(); +} = useExporter({ + onContentMissing(urls: string[]) { + openTaskDetailModal('存在未抓取内容的文章', `共 ${urls.length} 篇未抓取内容。默认展示前 10 条,可展开查看全部。`, urls); + }, +}); async function debug() { const cache = await getDebugCache('https://mp.weixin.qq.com/s/0IEaqpJIBGykHFKqj-7xqw'); @@ -570,11 +640,11 @@ function copyWechatLink() { ]" @export-article-excel="exportFile('excel', selectedArticleUrls)" @export-article-json="exportFile('json', selectedArticleUrls)" - @export-article-html="exportFile('html', selectedArticleUrls, contentNotDownloadedCount)" - @export-article-text="exportFile('text', selectedArticleUrls, contentNotDownloadedCount)" - @export-article-markdown="exportFile('markdown', selectedArticleUrls, contentNotDownloadedCount)" - @export-article-word="exportFile('word', selectedArticleUrls, contentNotDownloadedCount)" - @export-article-pdf="exportFile('pdf', selectedArticleUrls, contentNotDownloadedCount)" + @export-article-html="exportFile('html', selectedArticleUrls, contentNotDownloadedUrls)" + @export-article-text="exportFile('text', selectedArticleUrls, contentNotDownloadedUrls)" + @export-article-markdown="exportFile('markdown', selectedArticleUrls, contentNotDownloadedUrls)" + @export-article-word="exportFile('word', selectedArticleUrls, contentNotDownloadedUrls)" + @export-article-pdf="exportFile('pdf', selectedArticleUrls, contentNotDownloadedUrls)" >
+ {{ description }} +