mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 优化 AI 日报结果中心信息架构
This commit is contained in:
+9
-1
@@ -10,7 +10,11 @@ import { StickerService } from './sticker-service'
|
|||||||
import { parseMessageContent } from './message-parser'
|
import { parseMessageContent } from './message-parser'
|
||||||
import { ImageDecryptService } from './image-decrypt-service'
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
import { exportGroupReport } from './group-report-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 { GroupReportExportRequest } from '../shared/group-report'
|
||||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||||
import { DatabaseKeyStore } from './database-key-store'
|
import { DatabaseKeyStore } from './database-key-store'
|
||||||
@@ -343,6 +347,10 @@ app.whenReady().then(async () => {
|
|||||||
return saveGeneratedReport(request)
|
return saveGeneratedReport(request)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
|
||||||
|
return deleteGeneratedReport(reportId)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
try {
|
try {
|
||||||
shell.showItemInFolder(filePath)
|
shell.showItemInFolder(filePath)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type {
|
import type {
|
||||||
GeneratedReportRecord,
|
GeneratedReportRecord,
|
||||||
|
DeleteGeneratedReportResult,
|
||||||
ReportAssetStatus,
|
ReportAssetStatus,
|
||||||
ReportHistoryResult,
|
ReportHistoryResult,
|
||||||
SaveGeneratedReportRequest,
|
SaveGeneratedReportRequest,
|
||||||
@@ -166,3 +167,35 @@ export async function saveGeneratedReport(
|
|||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteGeneratedReport(reportId: string): Promise<DeleteGeneratedReportResult> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -2,6 +2,7 @@ import { ElectronAPI } from '@electron-toolkit/preload'
|
|||||||
import { Contact, Message } from '../shared/types'
|
import { Contact, Message } from '../shared/types'
|
||||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||||
import {
|
import {
|
||||||
|
DeleteGeneratedReportResult,
|
||||||
ReportHistoryResult,
|
ReportHistoryResult,
|
||||||
SaveGeneratedReportRequest,
|
SaveGeneratedReportRequest,
|
||||||
SaveGeneratedReportResult
|
SaveGeneratedReportResult
|
||||||
@@ -93,6 +94,7 @@ declare global {
|
|||||||
saveGeneratedReport: (
|
saveGeneratedReport: (
|
||||||
request: SaveGeneratedReportRequest
|
request: SaveGeneratedReportRequest
|
||||||
) => Promise<SaveGeneratedReportResult>
|
) => Promise<SaveGeneratedReportResult>
|
||||||
|
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||||
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||||
autoGetDbKey: () => Promise<{
|
autoGetDbKey: () => Promise<{
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const api = {
|
|||||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||||
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
|
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
|
||||||
ipcRenderer.invoke('report:saveGenerated', request),
|
ipcRenderer.invoke('report:saveGenerated', request),
|
||||||
|
deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
|
|||||||
@@ -216,7 +216,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
const reports = result.reports || []
|
const reports = result.reports || []
|
||||||
setGeneratedReports(reports)
|
setGeneratedReports(reports)
|
||||||
setSelectedReportId((current) => current || reports[0]?.id || null)
|
setSelectedReportId((current) =>
|
||||||
|
current && reports.some((report) => report.id === current) ? current : null
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setReportNotice(error instanceof Error ? error.message : String(error))
|
setReportNotice(error instanceof Error ? error.message : String(error))
|
||||||
window.setTimeout(() => setReportNotice(''), 3200)
|
window.setTimeout(() => setReportNotice(''), 3200)
|
||||||
@@ -783,7 +785,8 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
if (page === 'report') {
|
if (page === 'report') {
|
||||||
setReportWorkspaceView('result')
|
setReportWorkspaceView('result')
|
||||||
if (!selectedReportId && generatedReports[0]) setSelectedReportId(generatedReports[0].id)
|
setSelectedReportId(null)
|
||||||
|
setSelectedReportImageSize(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,10 +879,9 @@ function App(): React.ReactElement {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const selectedReport =
|
const selectedReport =
|
||||||
generatedReports.find((report) => report.id === selectedReportId) || generatedReports[0] || null
|
generatedReports.find((report) => report.id === selectedReportId) || null
|
||||||
|
|
||||||
const openReportResult = (): void => {
|
const openReportResult = (): void => {
|
||||||
if (generatedReports[0] && !selectedReportId) setSelectedReportId(generatedReports[0].id)
|
|
||||||
setReportWorkspaceView('result')
|
setReportWorkspaceView('result')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +912,20 @@ function App(): React.ReactElement {
|
|||||||
return window.api.revealGroupReport(filePath)
|
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<AppPage, 'archive' | 'report'>): React.ReactElement => {
|
const renderPlaceholderPage = (page: Exclude<AppPage, 'archive' | 'report'>): React.ReactElement => {
|
||||||
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
|
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
|
||||||
search: '检索',
|
search: '检索',
|
||||||
@@ -963,7 +979,7 @@ function App(): React.ReactElement {
|
|||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
<ReportHistorySidebar
|
<ReportHistorySidebar
|
||||||
reports={generatedReports}
|
reports={generatedReports}
|
||||||
selectedReportId={selectedReport?.id || null}
|
selectedReportId={selectedReportId}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={isAuthenticated}
|
dbReady={isAuthenticated}
|
||||||
onSelectReport={(reportId) => {
|
onSelectReport={(reportId) => {
|
||||||
@@ -971,10 +987,12 @@ function App(): React.ReactElement {
|
|||||||
setSelectedReportImageSize(null)
|
setSelectedReportImageSize(null)
|
||||||
}}
|
}}
|
||||||
onCreateReport={openReportConfigure}
|
onCreateReport={openReportConfigure}
|
||||||
|
onDeleteReport={handleDeleteReport}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
/>
|
/>
|
||||||
<ReportViewer
|
<ReportViewer
|
||||||
report={selectedReport}
|
report={selectedReport}
|
||||||
|
hasReports={generatedReports.length > 0}
|
||||||
onBackToConfigure={openReportConfigure}
|
onBackToConfigure={openReportConfigure}
|
||||||
onRegenerate={handleRegenerateReport}
|
onRegenerate={handleRegenerateReport}
|
||||||
onCopyImage={handleCopyReportImage}
|
onCopyImage={handleCopyReportImage}
|
||||||
|
|||||||
@@ -3526,28 +3526,16 @@ body {
|
|||||||
font: 13px/18px var(--wxex-font);
|
font: 13px/18px var(--wxex-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-history-filters {
|
.report-history-create {
|
||||||
display: grid;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
height: 36px;
|
||||||
gap: 6px;
|
margin: 0 14px 12px;
|
||||||
padding: 0 14px 10px;
|
border: 1px solid var(--wxex-brand);
|
||||||
}
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-brand);
|
||||||
.report-history-filters button {
|
color: #fff;
|
||||||
height: 28px;
|
|
||||||
border: 1px solid var(--wxex-border);
|
|
||||||
border-radius: var(--wxex-radius-sm);
|
|
||||||
background: var(--wxex-bg-elevated);
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: 12px/16px var(--wxex-font);
|
font: 700 13px/18px var(--wxex-font);
|
||||||
}
|
|
||||||
|
|
||||||
.report-history-filters button.active {
|
|
||||||
border-color: var(--wxex-brand);
|
|
||||||
background: var(--wxex-brand-soft);
|
|
||||||
color: var(--wxex-brand);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-history-list {
|
.report-history-list {
|
||||||
@@ -3557,6 +3545,23 @@ body {
|
|||||||
padding: 0 10px 12px;
|
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 {
|
.report-history-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3614,6 +3619,7 @@ body {
|
|||||||
|
|
||||||
.report-history-text {
|
.report-history-text {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
@@ -3637,6 +3643,28 @@ body {
|
|||||||
font: normal 12px/16px var(--wxex-font);
|
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 {
|
.report-history-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3664,6 +3692,65 @@ body {
|
|||||||
border-top: 1px solid var(--wxex-border);
|
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 {
|
.report-viewer {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3800,12 +3887,33 @@ body {
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
color: var(--wxex-text-secondary);
|
color: var(--wxex-text-secondary);
|
||||||
text-align: center;
|
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 {
|
.report-center-empty h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--wxex-text-primary);
|
color: var(--wxex-text-primary);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface ReportEmptyStateProps {
|
interface ReportEmptyStateProps {
|
||||||
|
icon?: 'spark'
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
actionLabel: string
|
actionLabel: string
|
||||||
@@ -8,6 +9,7 @@ interface ReportEmptyStateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReportEmptyState({
|
export function ReportEmptyState({
|
||||||
|
icon,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
@@ -15,6 +17,14 @@ export function ReportEmptyState({
|
|||||||
}: ReportEmptyStateProps): React.ReactElement {
|
}: ReportEmptyStateProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="report-center-empty">
|
<div className="report-center-empty">
|
||||||
|
{icon === 'spark' && (
|
||||||
|
<div className="report-center-empty-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
|
<path d="M12 3l1.6 5.2L19 10l-5.4 1.8L12 17l-1.6-5.2L5 10l5.4-1.8L12 3Z" />
|
||||||
|
<path d="M19 15l.8 2.3L22 18l-2.2.7L19 21l-.8-2.3L16 18l2.2-.7L19 15Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
<button type="button" onClick={onAction}>
|
<button type="button" onClick={onAction}>
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ interface ReportHistorySidebarProps {
|
|||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onSelectReport: (reportId: string) => void
|
onSelectReport: (reportId: string) => void
|
||||||
onCreateReport: () => void
|
onCreateReport: () => void
|
||||||
|
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryFilter = 'today' | 'yesterday' | 'week' | 'older'
|
interface ReportGroup {
|
||||||
|
label: string
|
||||||
|
reports: GeneratedReportRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
const DAY_MS = 86400000
|
const DAY_MS = 86400000
|
||||||
|
|
||||||
@@ -31,7 +35,44 @@ const dateKey = (value: string): number => {
|
|||||||
const formatGeneratedAt = (value: string): string => {
|
const formatGeneratedAt = (value: string): string => {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (!Number.isFinite(date.getTime())) return value
|
if (!Number.isFinite(date.getTime())) return value
|
||||||
return date.toLocaleString('zh-CN', { hour12: false })
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupLabelFor = (value: string): string => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (!Number.isFinite(date.getTime())) return '更早'
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||||
|
const startOfYesterday = startOfToday - DAY_MS
|
||||||
|
const time = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||||
|
|
||||||
|
if (time >= startOfToday) return '今天'
|
||||||
|
if (time >= startOfYesterday) return '昨天'
|
||||||
|
if (date.getFullYear() === now.getFullYear()) return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildGroups = (reports: GeneratedReportRecord[]): ReportGroup[] => {
|
||||||
|
const groups: ReportGroup[] = []
|
||||||
|
const groupMap = new Map<string, GeneratedReportRecord[]>()
|
||||||
|
|
||||||
|
reports.forEach((report) => {
|
||||||
|
const label = groupLabelFor(report.generatedAt)
|
||||||
|
const items = groupMap.get(label) || []
|
||||||
|
items.push(report)
|
||||||
|
groupMap.set(label, items)
|
||||||
|
})
|
||||||
|
|
||||||
|
groupMap.forEach((items, label) => groups.push({ label, reports: items }))
|
||||||
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportHistorySidebar({
|
export function ReportHistorySidebar({
|
||||||
@@ -41,37 +82,44 @@ export function ReportHistorySidebar({
|
|||||||
dbReady,
|
dbReady,
|
||||||
onSelectReport,
|
onSelectReport,
|
||||||
onCreateReport,
|
onCreateReport,
|
||||||
|
onDeleteReport,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ReportHistorySidebarProps): React.ReactElement {
|
}: ReportHistorySidebarProps): React.ReactElement {
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [filter, setFilter] = useState<HistoryFilter>('today')
|
const [pendingDelete, setPendingDelete] = useState<GeneratedReportRecord | null>(null)
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
|
||||||
const filteredReports = useMemo(() => {
|
const reportGroups = useMemo(() => {
|
||||||
const lower = keyword.trim().toLowerCase()
|
const lower = keyword.trim().toLowerCase()
|
||||||
const now = new Date()
|
const filteredReports = reports
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
||||||
const startOfYesterday = startOfToday - DAY_MS
|
|
||||||
const startOfWeek = startOfToday - 6 * DAY_MS
|
|
||||||
|
|
||||||
return reports
|
|
||||||
.filter((report) => {
|
.filter((report) => {
|
||||||
const haystack = `${report.contactName} ${report.dateRange}`.toLowerCase()
|
const haystack = `${report.contactName} ${report.dateRange} ${formatGeneratedAt(
|
||||||
if (lower && !haystack.includes(lower)) return false
|
report.generatedAt
|
||||||
const time = dateKey(report.generatedAt)
|
)}`.toLowerCase()
|
||||||
if (filter === 'today') return time >= startOfToday
|
return lower ? haystack.includes(lower) : true
|
||||||
if (filter === 'yesterday') return time >= startOfYesterday && time < startOfToday
|
|
||||||
if (filter === 'week') return time >= startOfWeek
|
|
||||||
return time < startOfWeek
|
|
||||||
})
|
})
|
||||||
.sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt))
|
.sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt))
|
||||||
}, [filter, keyword, reports])
|
|
||||||
|
return buildGroups(filteredReports)
|
||||||
|
}, [keyword, reports])
|
||||||
|
|
||||||
|
const confirmDelete = async (): Promise<void> => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
setDeleteError('')
|
||||||
|
const result = await onDeleteReport(pendingDelete.id)
|
||||||
|
if (!result.success) {
|
||||||
|
setDeleteError(result.error || '删除日报失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPendingDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="report-history-sidebar">
|
<aside className="report-history-sidebar">
|
||||||
<div className="report-history-header">
|
<div className="report-history-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>AI 日报</h2>
|
<h2>AI 日报</h2>
|
||||||
<p>本地保存的群聊日报资产</p>
|
<p>已生成报告管理中心</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{reports.length}</span>
|
<span>{reports.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,64 +134,93 @@ export function ReportHistorySidebar({
|
|||||||
placeholder="搜索群聊或日报"
|
placeholder="搜索群聊或日报"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="report-history-filters">
|
<button type="button" className="report-history-create" onClick={onCreateReport}>
|
||||||
{[
|
+ 新建日报
|
||||||
['today', '今天'],
|
|
||||||
['yesterday', '昨天'],
|
|
||||||
['week', '本周'],
|
|
||||||
['older', '更早']
|
|
||||||
].map(([value, label]) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
className={filter === value ? 'active' : ''}
|
|
||||||
onClick={() => setFilter(value as HistoryFilter)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
<div className="report-history-list" aria-label="历史报告">
|
||||||
</div>
|
<div className="report-history-list-title">历史报告</div>
|
||||||
<div className="report-history-list">
|
{reportGroups.length ? (
|
||||||
{filteredReports.length ? (
|
reportGroups.map((group) => (
|
||||||
filteredReports.map((report) => (
|
<section className="report-history-group" key={group.label}>
|
||||||
<button
|
<h3>{group.label}</h3>
|
||||||
|
{group.reports.map((report) => (
|
||||||
|
<div
|
||||||
key={report.id}
|
key={report.id}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={`report-history-item ${report.id === selectedReportId ? 'active' : ''}`}
|
className={`report-history-item ${report.id === selectedReportId ? 'active' : ''}`}
|
||||||
onClick={() => onSelectReport(report.id)}
|
onClick={() => onSelectReport(report.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
onSelectReport(report.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="report-history-avatar">
|
<span className="report-history-avatar">
|
||||||
{report.contactAvatar ? (
|
{report.contactAvatar ? (
|
||||||
<img src={report.contactAvatar} alt={report.contactName} referrerPolicy="no-referrer" />
|
<img
|
||||||
|
src={report.contactAvatar}
|
||||||
|
alt={report.contactName}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
report.contactName.charAt(0)
|
report.contactName.charAt(0)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="report-history-text">
|
<span className="report-history-text">
|
||||||
<b>{report.contactName}</b>
|
<b>{report.contactName}</b>
|
||||||
<small>
|
<small>{formatGeneratedAt(report.generatedAt)}</small>
|
||||||
{report.dateRange} · {formatGeneratedAt(report.generatedAt)}
|
|
||||||
</small>
|
|
||||||
<small>{report.messageCount} 条消息</small>
|
<small>{report.messageCount} 条消息</small>
|
||||||
<em>
|
<em>PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'}</em>
|
||||||
HTML {report.htmlStatus === 'ready' ? '已保存' : '缺失'} · PNG{' '}
|
|
||||||
{report.pngStatus === 'ready' ? '已保存' : '缺失'}
|
|
||||||
</em>
|
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-history-delete"
|
||||||
|
title="删除日报"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setPendingDelete(report)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setPendingDelete(report)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="report-history-empty">
|
<div className="report-history-empty">
|
||||||
<b>当前分组暂无日报</b>
|
<b>暂无历史报告</b>
|
||||||
<button type="button" onClick={onCreateReport}>
|
<span>新建日报后会出现在这里。</span>
|
||||||
生成新日报
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="report-history-account">
|
<div className="report-history-account">
|
||||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||||
</div>
|
</div>
|
||||||
|
{pendingDelete && (
|
||||||
|
<div className="report-delete-confirm" role="dialog" aria-modal="true">
|
||||||
|
<div className="report-delete-confirm-card">
|
||||||
|
<h2>删除日报?</h2>
|
||||||
|
<p>只删除本地生成报告,不会影响微信聊天记录。</p>
|
||||||
|
{deleteError && <p className="report-delete-error">{deleteError}</p>}
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => setPendingDelete(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger" onClick={() => void confirmDelete()}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function ReportSettingsPanel({
|
|||||||
<aside className="report-settings-panel">
|
<aside className="report-settings-panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>报告信息</h2>
|
<h2>报告信息</h2>
|
||||||
<p>当前本地日报资产的真实保存状态</p>
|
<p>当前选中报告的本地资产状态</p>
|
||||||
</header>
|
</header>
|
||||||
<section className="report-settings-section">
|
<section className="report-settings-section">
|
||||||
<h3>生成信息</h3>
|
<h3>生成信息</h3>
|
||||||
@@ -75,8 +75,8 @@ export function ReportSettingsPanel({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="report-settings-section muted">
|
<section className="report-settings-section muted">
|
||||||
<h3>暂未支持</h3>
|
<h3>说明</h3>
|
||||||
<p>云同步、报告编辑器、模板切换和复杂历史数据库不属于本阶段。</p>
|
<p>删除历史日报只会删除本地生成报告,不会影响微信聊天记录。</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ReportZoomBar } from './ReportZoomBar'
|
|||||||
|
|
||||||
interface ReportViewerProps {
|
interface ReportViewerProps {
|
||||||
report: GeneratedReportRecord | null
|
report: GeneratedReportRecord | null
|
||||||
|
hasReports: boolean
|
||||||
onBackToConfigure: () => void
|
onBackToConfigure: () => void
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
|
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -15,6 +16,7 @@ interface ReportViewerProps {
|
|||||||
|
|
||||||
export function ReportViewer({
|
export function ReportViewer({
|
||||||
report,
|
report,
|
||||||
|
hasReports,
|
||||||
onBackToConfigure,
|
onBackToConfigure,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
@@ -39,7 +41,7 @@ export function ReportViewer({
|
|||||||
}, [onImageSizeChange, report?.id])
|
}, [onImageSizeChange, report?.id])
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(
|
||||||
() => (report ? `${report.contactName} 群聊日报` : 'AI 群聊日报'),
|
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
|
||||||
[report]
|
[report]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,9 +68,14 @@ export function ReportViewer({
|
|||||||
return (
|
return (
|
||||||
<main className="report-viewer">
|
<main className="report-viewer">
|
||||||
<ReportEmptyState
|
<ReportEmptyState
|
||||||
title="尚未生成日报"
|
icon="spark"
|
||||||
message="生成一份 AI 群聊日报后,可以在这里查看本地保存的长图。"
|
title={hasReports ? '选择一份历史日报' : 'AI日报'}
|
||||||
actionLabel="生成新日报"
|
message={
|
||||||
|
hasReports
|
||||||
|
? '从左侧历史报告中选择一份日报,查看本地保存的长图和文件信息。'
|
||||||
|
: '还没有生成过日报。选择一个群聊,让 AI 自动整理讨论重点、热点话题和重要消息。'
|
||||||
|
}
|
||||||
|
actionLabel="开始生成日报"
|
||||||
onAction={onBackToConfigure}
|
onAction={onBackToConfigure}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ export interface SaveGeneratedReportResult {
|
|||||||
record?: GeneratedReportRecord
|
record?: GeneratedReportRecord
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteGeneratedReportResult {
|
||||||
|
success: boolean
|
||||||
|
deletedId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user