feat: 优化 AI 日报结果中心信息架构

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent c291c94bd8
commit d28b579cb4
11 changed files with 372 additions and 102 deletions
+9 -1
View File
@@ -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)
+33
View File
@@ -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<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) }
}
}
+2
View File
@@ -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<SaveGeneratedReportResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
autoGetDbKey: () => Promise<{
+1
View File
@@ -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'),
+23 -5
View File
@@ -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<AppPage, 'archive' | 'report'>): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索',
@@ -963,7 +979,7 @@ function App(): React.ReactElement {
<div className="report-center-page">
<ReportHistorySidebar
reports={generatedReports}
selectedReportId={selectedReport?.id || null}
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectReport={(reportId) => {
@@ -971,10 +987,12 @@ function App(): React.ReactElement {
setSelectedReportImageSize(null)
}}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
onOpenSettings={() => setShowSettings(true)}
/>
<ReportViewer
report={selectedReport}
hasReports={generatedReports.length > 0}
onBackToConfigure={openReportConfigure}
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
+129 -21
View File
@@ -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);
@@ -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 (
<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>
<p>{message}</p>
<button type="button" onClick={onAction}>
@@ -16,10 +16,14 @@ interface ReportHistorySidebarProps {
dbReady: boolean
onSelectReport: (reportId: string) => void
onCreateReport: () => void
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
onOpenSettings: () => void
}
type HistoryFilter = 'today' | 'yesterday' | 'week' | 'older'
interface ReportGroup {
label: string
reports: GeneratedReportRecord[]
}
const DAY_MS = 86400000
@@ -31,7 +35,44 @@ const dateKey = (value: string): number => {
const formatGeneratedAt = (value: string): string => {
const date = new Date(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({
@@ -41,37 +82,44 @@ export function ReportHistorySidebar({
dbReady,
onSelectReport,
onCreateReport,
onDeleteReport,
onOpenSettings
}: ReportHistorySidebarProps): React.ReactElement {
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 now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const startOfYesterday = startOfToday - DAY_MS
const startOfWeek = startOfToday - 6 * DAY_MS
return reports
const filteredReports = reports
.filter((report) => {
const haystack = `${report.contactName} ${report.dateRange}`.toLowerCase()
if (lower && !haystack.includes(lower)) return false
const time = dateKey(report.generatedAt)
if (filter === 'today') return time >= startOfToday
if (filter === 'yesterday') return time >= startOfYesterday && time < startOfToday
if (filter === 'week') return time >= startOfWeek
return time < startOfWeek
const haystack = `${report.contactName} ${report.dateRange} ${formatGeneratedAt(
report.generatedAt
)}`.toLowerCase()
return lower ? haystack.includes(lower) : true
})
.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 (
<aside className="report-history-sidebar">
<div className="report-history-header">
<div>
<h2>AI </h2>
<p></p>
<p></p>
</div>
<span>{reports.length}</span>
</div>
@@ -86,64 +134,93 @@ export function ReportHistorySidebar({
placeholder="搜索群聊或日报"
/>
</label>
<div className="report-history-filters">
{[
['today', '今天'],
['yesterday', '昨天'],
['week', '本周'],
['older', '更早']
].map(([value, label]) => (
<button
key={value}
type="button"
className={filter === value ? 'active' : ''}
onClick={() => setFilter(value as HistoryFilter)}
>
{label}
<button type="button" className="report-history-create" onClick={onCreateReport}>
+
</button>
))}
</div>
<div className="report-history-list">
{filteredReports.length ? (
filteredReports.map((report) => (
<button
<div className="report-history-list" aria-label="历史报告">
<div className="report-history-list-title"></div>
{reportGroups.length ? (
reportGroups.map((group) => (
<section className="report-history-group" key={group.label}>
<h3>{group.label}</h3>
{group.reports.map((report) => (
<div
key={report.id}
type="button"
role="button"
tabIndex={0}
className={`report-history-item ${report.id === selectedReportId ? 'active' : ''}`}
onClick={() => onSelectReport(report.id)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelectReport(report.id)
}}
>
<span className="report-history-avatar">
{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)
)}
</span>
<span className="report-history-text">
<b>{report.contactName}</b>
<small>
{report.dateRange} · {formatGeneratedAt(report.generatedAt)}
</small>
<small>{formatGeneratedAt(report.generatedAt)}</small>
<small>{report.messageCount} </small>
<em>
HTML {report.htmlStatus === 'ready' ? '已保存' : '缺失'} · PNG{' '}
{report.pngStatus === 'ready' ? '已保存' : '缺失'}
</em>
<em>PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'}</em>
</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>
</div>
))}
</section>
))
) : (
<div className="report-history-empty">
<b></b>
<button type="button" onClick={onCreateReport}>
</button>
<b></b>
<span></span>
</div>
)}
</div>
<div className="report-history-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
</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>
)
}
@@ -36,7 +36,7 @@ export function ReportSettingsPanel({
<aside className="report-settings-panel">
<header>
<h2></h2>
<p></p>
<p></p>
</header>
<section className="report-settings-section">
<h3></h3>
@@ -75,8 +75,8 @@ export function ReportSettingsPanel({
)}
</section>
<section className="report-settings-section muted">
<h3></h3>
<p></p>
<h3></h3>
<p></p>
</section>
</aside>
)
@@ -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 (
<main className="report-viewer">
<ReportEmptyState
title="尚未生成日报"
message="生成一份 AI 群聊日报后,可以在这里查看本地保存的长图。"
actionLabel="生成新日报"
icon="spark"
title={hasReports ? '选择一份历史日报' : 'AI日报'}
message={
hasReports
? '从左侧历史报告中选择一份日报,查看本地保存的长图和文件信息。'
: '还没有生成过日报。选择一个群聊,让 AI 自动整理讨论重点、热点话题和重要消息。'
}
actionLabel="开始生成日报"
onAction={onBackToConfigure}
/>
</main>
+6
View File
@@ -40,3 +40,9 @@ export interface SaveGeneratedReportResult {
record?: GeneratedReportRecord
error?: string
}
export interface DeleteGeneratedReportResult {
success: boolean
deletedId?: string
error?: string
}