diff --git a/src/main/index.ts b/src/main/index.ts index c65bc2d..788e870 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,11 @@ import { StickerService } from './sticker-service' import { parseMessageContent } from './message-parser' import { ImageDecryptService } from './image-decrypt-service' import { exportGroupReport } from './group-report-service' -import { listGeneratedReports, saveGeneratedReport } from './report-history-service' +import { + deleteGeneratedReport, + listGeneratedReports, + saveGeneratedReport +} from './report-history-service' import type { GroupReportExportRequest } from '../shared/group-report' import type { SaveGeneratedReportRequest } from '../shared/report-history' import { DatabaseKeyStore } from './database-key-store' @@ -343,6 +347,10 @@ app.whenReady().then(async () => { return saveGeneratedReport(request) }) + ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => { + return deleteGeneratedReport(reportId) + }) + ipcMain.handle('report:reveal', async (_, filePath: string) => { try { shell.showItemInFolder(filePath) diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts index c8b1a02..4bea158 100644 --- a/src/main/report-history-service.ts +++ b/src/main/report-history-service.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs' import path from 'path' import type { GeneratedReportRecord, + DeleteGeneratedReportResult, ReportAssetStatus, ReportHistoryResult, SaveGeneratedReportRequest, @@ -166,3 +167,35 @@ export async function saveGeneratedReport( return { success: false, error: error instanceof Error ? error.message : String(error) } } } + +export async function deleteGeneratedReport(reportId: string): Promise { + try { + const jsonFiles = await walkJsonFiles(getReportsRoot()) + for (const jsonPath of jsonFiles) { + try { + const content = await fs.readFile(jsonPath, 'utf8') + const record = JSON.parse(content) as GeneratedReportRecord + if (record.id !== reportId) continue + + const paths = [record.htmlPath, record.pngPath, jsonPath].filter( + (filePath): filePath is string => Boolean(filePath) + ) + await Promise.all( + paths.map(async (filePath) => { + try { + await fs.unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + }) + ) + return { success: true, deletedId: reportId } + } catch (error) { + console.warn(`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`, error) + } + } + return { success: false, error: '未找到要删除的日报记录' } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 55ebffe..b64711c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -2,6 +2,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { Contact, Message } from '../shared/types' import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report' import { + DeleteGeneratedReportResult, ReportHistoryResult, SaveGeneratedReportRequest, SaveGeneratedReportResult @@ -93,6 +94,7 @@ declare global { saveGeneratedReport: ( request: SaveGeneratedReportRequest ) => Promise + deleteGeneratedReport: (reportId: string) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> autoGetDbKey: () => Promise<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index 42c6288..8996c59 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,6 +40,7 @@ const api = { listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'), saveGeneratedReport: (request: SaveGeneratedReportRequest) => ipcRenderer.invoke('report:saveGenerated', request), + deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8e295f7..1f69d3e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -216,7 +216,9 @@ function App(): React.ReactElement { } const reports = result.reports || [] setGeneratedReports(reports) - setSelectedReportId((current) => current || reports[0]?.id || null) + setSelectedReportId((current) => + current && reports.some((report) => report.id === current) ? current : null + ) } catch (error) { setReportNotice(error instanceof Error ? error.message : String(error)) window.setTimeout(() => setReportNotice(''), 3200) @@ -783,7 +785,8 @@ function App(): React.ReactElement { } if (page === 'report') { setReportWorkspaceView('result') - if (!selectedReportId && generatedReports[0]) setSelectedReportId(generatedReports[0].id) + setSelectedReportId(null) + setSelectedReportImageSize(null) } } @@ -876,10 +879,9 @@ function App(): React.ReactElement { ]) const selectedReport = - generatedReports.find((report) => report.id === selectedReportId) || generatedReports[0] || null + generatedReports.find((report) => report.id === selectedReportId) || null const openReportResult = (): void => { - if (generatedReports[0] && !selectedReportId) setSelectedReportId(generatedReports[0].id) setReportWorkspaceView('result') } @@ -910,6 +912,20 @@ function App(): React.ReactElement { return window.api.revealGroupReport(filePath) } + const handleDeleteReport = async ( + reportId: string + ): Promise<{ success: boolean; error?: string }> => { + const result = await window.api.deleteGeneratedReport(reportId) + if (!result.success) return { success: false, error: result.error || '删除日报失败' } + + setGeneratedReports((current) => current.filter((report) => report.id !== reportId)) + if (selectedReportId === reportId) { + setSelectedReportId(null) + setSelectedReportImageSize(null) + } + return { success: true } + } + const renderPlaceholderPage = (page: Exclude): React.ReactElement => { const labels: Record, string> = { search: '检索', @@ -963,7 +979,7 @@ function App(): React.ReactElement {
{ @@ -971,10 +987,12 @@ function App(): React.ReactElement { setSelectedReportImageSize(null) }} onCreateReport={openReportConfigure} + onDeleteReport={handleDeleteReport} onOpenSettings={() => setShowSettings(true)} /> 0} onBackToConfigure={openReportConfigure} onRegenerate={handleRegenerateReport} onCopyImage={handleCopyReportImage} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 2fd858c..55481bc 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3526,28 +3526,16 @@ body { font: 13px/18px var(--wxex-font); } -.report-history-filters { - display: grid; +.report-history-create { flex: 0 0 auto; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - padding: 0 14px 10px; -} - -.report-history-filters button { - height: 28px; - border: 1px solid var(--wxex-border); - border-radius: var(--wxex-radius-sm); - background: var(--wxex-bg-elevated); - color: var(--wxex-text-secondary); + height: 36px; + margin: 0 14px 12px; + border: 1px solid var(--wxex-brand); + border-radius: var(--wxex-radius-md); + background: var(--wxex-brand); + color: #fff; cursor: pointer; - font: 12px/16px var(--wxex-font); -} - -.report-history-filters button.active { - border-color: var(--wxex-brand); - background: var(--wxex-brand-soft); - color: var(--wxex-brand); + font: 700 13px/18px var(--wxex-font); } .report-history-list { @@ -3557,6 +3545,23 @@ body { padding: 0 10px 12px; } +.report-history-list-title { + padding: 4px 4px 8px; + color: var(--wxex-text-secondary); + font: 700 13px/18px var(--wxex-font); +} + +.report-history-group { + margin: 0 0 10px; +} + +.report-history-group h3 { + margin: 0; + padding: 6px 4px; + color: var(--wxex-text-muted); + font: 700 12px/16px var(--wxex-font); +} + .report-history-item { position: relative; display: flex; @@ -3614,6 +3619,7 @@ body { .report-history-text { display: grid; + flex: 1 1 auto; min-width: 0; gap: 2px; } @@ -3637,6 +3643,28 @@ body { font: normal 12px/16px var(--wxex-font); } +.report-history-delete { + display: grid; + width: 38px; + height: 26px; + flex: 0 0 auto; + place-items: center; + align-self: center; + border: 1px solid rgba(198, 72, 72, 0.22); + border-radius: var(--wxex-radius-sm); + background: rgba(198, 72, 72, 0.06); + color: #a64242; + cursor: pointer; + font: 600 12px/16px var(--wxex-font); + opacity: 0; + transition: opacity 0.12s ease; +} + +.report-history-item:hover .report-history-delete, +.report-history-delete:focus { + opacity: 1; +} + .report-history-empty { display: grid; gap: 10px; @@ -3664,6 +3692,65 @@ body { border-top: 1px solid var(--wxex-border); } +.report-delete-confirm { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + background: rgba(32, 39, 36, 0.28); +} + +.report-delete-confirm-card { + display: grid; + width: min(360px, calc(100vw - 48px)); + gap: 12px; + padding: 18px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-lg); + background: var(--wxex-bg-main); + box-shadow: var(--wxex-shadow-popover); +} + +.report-delete-confirm-card h2 { + margin: 0; + color: var(--wxex-text-primary); + font: 700 18px/24px var(--wxex-font); +} + +.report-delete-confirm-card p { + margin: 0; + color: var(--wxex-text-secondary); + font: 13px/20px var(--wxex-font); +} + +.report-delete-confirm-card > div { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.report-delete-confirm-card button { + min-height: 32px; + padding: 0 14px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-primary); + cursor: pointer; + font: 600 13px/18px var(--wxex-font); +} + +.report-delete-confirm-card button.danger { + border-color: #a64242; + background: #a64242; + color: #fff; +} + +.report-delete-error { + color: #a64242 !important; +} + .report-viewer { display: flex; min-width: 0; @@ -3800,12 +3887,33 @@ body { align-self: center; justify-self: center; max-width: 360px; - gap: 10px; + gap: 12px; margin: auto; color: var(--wxex-text-secondary); text-align: center; } +.report-center-empty-icon { + display: grid; + width: 54px; + height: 54px; + place-items: center; + justify-self: center; + border: 1px solid rgba(104, 110, 220, 0.18); + border-radius: 18px; + background: var(--wxex-ai-soft); + color: var(--wxex-ai); +} + +.report-center-empty-icon svg { + width: 28px; + height: 28px; +} + +.report-center-empty-icon path { + fill: currentColor; +} + .report-center-empty h2 { margin: 0; color: var(--wxex-text-primary); diff --git a/src/renderer/src/components/reports/ReportEmptyState.tsx b/src/renderer/src/components/reports/ReportEmptyState.tsx index 6a0ffaa..7a587d9 100644 --- a/src/renderer/src/components/reports/ReportEmptyState.tsx +++ b/src/renderer/src/components/reports/ReportEmptyState.tsx @@ -1,6 +1,7 @@ import React from 'react' interface ReportEmptyStateProps { + icon?: 'spark' title: string message: string actionLabel: string @@ -8,6 +9,7 @@ interface ReportEmptyStateProps { } export function ReportEmptyState({ + icon, title, message, actionLabel, @@ -15,6 +17,14 @@ export function ReportEmptyState({ }: ReportEmptyStateProps): React.ReactElement { return (
+ {icon === 'spark' && ( + + )}

{title}

{message}

- ))} -
-
- {filteredReports.length ? ( - filteredReports.map((report) => ( - + +
+
历史报告
+ {reportGroups.length ? ( + reportGroups.map((group) => ( +
+

{group.label}

+ {group.reports.map((report) => ( +
onSelectReport(report.id)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + onSelectReport(report.id) + }} + > + + {report.contactAvatar ? ( + {report.contactName} + ) : ( + report.contactName.charAt(0) + )} + + + {report.contactName} + {formatGeneratedAt(report.generatedAt)} + {report.messageCount} 条消息 + PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'} + + +
+ ))} +
)) ) : (
- 当前分组暂无日报 - + 暂无历史报告 + 新建日报后会出现在这里。
)}
+ {pendingDelete && ( +
+
+

删除日报?

+

只删除本地生成报告,不会影响微信聊天记录。

+ {deleteError &&

{deleteError}

} +
+ + +
+
+
+ )} ) } diff --git a/src/renderer/src/components/reports/ReportSettingsPanel.tsx b/src/renderer/src/components/reports/ReportSettingsPanel.tsx index c1ed844..16ec186 100644 --- a/src/renderer/src/components/reports/ReportSettingsPanel.tsx +++ b/src/renderer/src/components/reports/ReportSettingsPanel.tsx @@ -36,7 +36,7 @@ export function ReportSettingsPanel({ ) diff --git a/src/renderer/src/components/reports/ReportViewer.tsx b/src/renderer/src/components/reports/ReportViewer.tsx index dc2cb67..fe8b415 100644 --- a/src/renderer/src/components/reports/ReportViewer.tsx +++ b/src/renderer/src/components/reports/ReportViewer.tsx @@ -6,6 +6,7 @@ import { ReportZoomBar } from './ReportZoomBar' interface ReportViewerProps { report: GeneratedReportRecord | null + hasReports: boolean onBackToConfigure: () => void onRegenerate: () => void onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> @@ -15,6 +16,7 @@ interface ReportViewerProps { export function ReportViewer({ report, + hasReports, onBackToConfigure, onRegenerate, onCopyImage, @@ -39,7 +41,7 @@ export function ReportViewer({ }, [onImageSizeChange, report?.id]) const title = useMemo( - () => (report ? `${report.contactName} 群聊日报` : 'AI 群聊日报'), + () => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report] ) @@ -66,9 +68,14 @@ export function ReportViewer({ return (
diff --git a/src/shared/report-history.ts b/src/shared/report-history.ts index f94d6e9..352ade1 100644 --- a/src/shared/report-history.ts +++ b/src/shared/report-history.ts @@ -40,3 +40,9 @@ export interface SaveGeneratedReportResult { record?: GeneratedReportRecord error?: string } + +export interface DeleteGeneratedReportResult { + success: boolean + deletedId?: string + error?: string +}