feat: 实现 AI 日报历史资产化

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent 16a9aed6bb
commit c291c94bd8
16 changed files with 1488 additions and 41 deletions
+11 -1
View File
@@ -10,7 +10,9 @@ import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { 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'
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
@@ -333,6 +335,14 @@ app.whenReady().then(async () => {
return exportGroupReport(request)
})
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
ipcMain.handle('report:saveGenerated', async (_, request: SaveGeneratedReportRequest) => {
return saveGeneratedReport(request)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
+168
View File
@@ -0,0 +1,168 @@
import { app } from 'electron'
import { promises as fs } from 'fs'
import path from 'path'
import type {
GeneratedReportRecord,
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
} from '../shared/report-history'
const REPORTS_DIR = 'reports'
const getReportsRoot = (): string => path.join(app.getPath('userData'), REPORTS_DIR)
const pad2 = (value: number): string => String(value).padStart(2, '0')
const safeSegment = (value: string): string =>
value
.trim()
// Strip Windows-reserved filename characters and ASCII control chars.
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 48) || 'report'
const parseDataUrl = (dataUrl: string): Buffer | null => {
const match = /^data:image\/png;base64,(.+)$/i.exec(dataUrl)
if (!match) return null
return Buffer.from(match[1], 'base64')
}
const exists = async (filePath?: string): Promise<boolean> => {
if (!filePath) return false
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
const fileStatus = async (filePath?: string): Promise<ReportAssetStatus> =>
(await exists(filePath)) ? 'ready' : 'missing'
const readPngAsDataUrl = async (filePath?: string): Promise<string | undefined> => {
if (!filePath || !(await exists(filePath))) return undefined
const content = await fs.readFile(filePath)
return `data:image/png;base64,${content.toString('base64')}`
}
const normalizeRecord = async (
record: GeneratedReportRecord,
jsonPath: string
): Promise<GeneratedReportRecord> => {
const htmlStatus = await fileStatus(record.htmlPath)
const pngStatus = await fileStatus(record.pngPath)
return {
...record,
jsonPath,
htmlStatus,
pngStatus,
generatedImage: await readPngAsDataUrl(record.pngPath)
}
}
const walkJsonFiles = async (directory: string): Promise<string[]> => {
try {
const entries = await fs.readdir(directory, { withFileTypes: true })
const children = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) return walkJsonFiles(entryPath)
return entry.isFile() && entry.name.endsWith('.json') ? [entryPath] : []
})
)
return children.flat()
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
export async function listGeneratedReports(): Promise<ReportHistoryResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
const records = await Promise.all(
jsonFiles.map(async (jsonPath) => {
try {
const content = await fs.readFile(jsonPath, 'utf8')
return normalizeRecord(JSON.parse(content) as GeneratedReportRecord, jsonPath)
} catch (error) {
console.warn(`[ReportHistory] skip invalid report record: ${jsonPath}`, error)
return null
}
})
)
const reports = records.filter((record): record is GeneratedReportRecord => Boolean(record))
reports.sort((left, right) => Date.parse(right.generatedAt) - Date.parse(left.generatedAt))
return { success: true, reports }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function saveGeneratedReport(
request: SaveGeneratedReportRequest
): Promise<SaveGeneratedReportResult> {
try {
const generatedAtDate = new Date(request.generatedAt)
const timestamp = Number.isFinite(generatedAtDate.getTime())
? generatedAtDate
: new Date()
const year = String(timestamp.getFullYear())
const month = pad2(timestamp.getMonth() + 1)
const directory = path.join(getReportsRoot(), year, month)
await fs.mkdir(directory, { recursive: true })
const id = `report_${timestamp.getTime()}_${Math.random().toString(36).slice(2, 8)}`
const baseName = `${id}_${safeSegment(request.contactName)}`
const htmlPath = path.join(directory, `${baseName}.html`)
const pngPath = path.join(directory, `${baseName}.png`)
const jsonPath = path.join(directory, `${baseName}.json`)
let savedHtmlPath: string | undefined
if (request.htmlPath && (await exists(request.htmlPath))) {
await fs.copyFile(request.htmlPath, htmlPath)
savedHtmlPath = htmlPath
}
let savedPngPath: string | undefined
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
if (imageBuffer) {
await fs.writeFile(pngPath, imageBuffer)
savedPngPath = pngPath
} else if (request.pngPath && (await exists(request.pngPath))) {
await fs.copyFile(request.pngPath, pngPath)
savedPngPath = pngPath
}
const record: GeneratedReportRecord = {
id,
contactId: request.contactId,
contactName: request.contactName,
contactAvatar: request.contactAvatar,
dateRange: request.dateRange,
messageCount: request.messageCount,
generatedAt: timestamp.toISOString(),
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`,
htmlPath: savedHtmlPath,
pngPath: savedPngPath,
jsonPath,
htmlStatus: savedHtmlPath ? 'ready' : 'missing',
pngStatus: savedPngPath ? 'ready' : 'missing'
}
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
return {
success: true,
record: {
...record,
generatedImage: savedPngPath ? await readPngAsDataUrl(savedPngPath) : undefined
}
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+9
View File
@@ -1,6 +1,11 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
import {
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
} from '../shared/report-history'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -84,6 +89,10 @@ declare global {
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: (
request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
autoGetDbKey: () => Promise<{
+5 -1
View File
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { GroupReportExportRequest } from '../shared/group-report'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
// 渲染器的自定义 API
const api = {
@@ -36,6 +37,9 @@ const api = {
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
ipcRenderer.invoke('report:saveGenerated', request),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
+202 -37
View File
@@ -5,8 +5,13 @@ import { SettingsPanel } from './components/SettingsPanel'
import { AppShell } from './components/layout/AppShell'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
import { ReportSettingsPanel } from './components/reports/ReportSettingsPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
import {
AiModelConfig,
useGroupReportGeneration
@@ -167,6 +172,13 @@ function App(): React.ReactElement {
const [showSettings, setShowSettings] = useState(false)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [selectedReportImageSize, setSelectedReportImageSize] = useState<{
width: number
height: number
} | null>(null)
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
@@ -192,6 +204,24 @@ function App(): React.ReactElement {
summaryMessageTypes,
modelConfig: aiModelConfig
})
const lastCapturedReportKeyRef = React.useRef('')
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
try {
const result = await window.api.listGeneratedReports()
if (!result.success) {
setReportNotice(result.error || '日报历史加载失败')
window.setTimeout(() => setReportNotice(''), 3200)
return
}
const reports = result.reports || []
setGeneratedReports(reports)
setSelectedReportId((current) => current || reports[0]?.id || null)
} catch (error) {
setReportNotice(error instanceof Error ? error.message : String(error))
window.setTimeout(() => setReportNotice(''), 3200)
}
}, [])
const waitForPaint = (): Promise<void> =>
new Promise((resolve) => window.setTimeout(resolve, 80))
@@ -369,6 +399,11 @@ function App(): React.ReactElement {
}
}, [])
React.useEffect(() => {
if (!isAuthenticated) return
void loadGeneratedReports()
}, [isAuthenticated, loadGeneratedReports])
const handleLogin = async (keyInput?: string): Promise<void> => {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
@@ -746,6 +781,10 @@ function App(): React.ReactElement {
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
}
if (page === 'report') {
setReportWorkspaceView('result')
if (!selectedReportId && generatedReports[0]) setSelectedReportId(generatedReports[0].id)
}
}
const handleOpenReportWorkspace = (): void => {
@@ -761,6 +800,7 @@ function App(): React.ReactElement {
}
setReportNotice('')
setReportSourceContact(selectedContact)
setReportWorkspaceView('configure')
setActivePage('report')
}
@@ -777,6 +817,99 @@ function App(): React.ReactElement {
localStorage.setItem('ai_model', aiModelConfig.model)
}
React.useEffect(() => {
if (
reportGeneration.phase !== 'success' ||
!reportSourceContact ||
!reportGeneration.generatedImage ||
!reportGeneration.reportPaths
) {
return
}
const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}`
if (lastCapturedReportKeyRef.current === recordKey) return
lastCapturedReportKeyRef.current = recordKey
const saveReport = async (): Promise<void> => {
const result = await window.api.saveGeneratedReport({
contactId: reportSourceContact.md5,
contactName: contactDisplayName(reportSourceContact),
contactAvatar: reportSourceContact.avatar || undefined,
dateRange:
summaryDateRange === 'yesterday'
? '昨日'
: summaryDateRange === '7days'
? '近 7 天'
: '今天',
messageCount: reportGeneration.reportMessages.length,
generatedAt: new Date().toISOString(),
generatedImage: reportGeneration.generatedImage || undefined,
htmlPath: reportGeneration.reportPaths?.htmlPath,
pngPath: reportGeneration.reportPaths?.pngPath
})
if (!result.success || !result.record) {
setReportNotice(result.error || '日报保存失败')
window.setTimeout(() => setReportNotice(''), 3200)
return
}
setGeneratedReports((current) => [
result.record as GeneratedReportRecord,
...current.filter((report) => report.id !== result.record?.id)
])
setSelectedReportId(result.record.id)
setSelectedReportImageSize(null)
setReportWorkspaceView('result')
setActivePage('report')
}
void saveReport()
}, [
reportGeneration.generatedImage,
reportGeneration.phase,
reportGeneration.reportMessages.length,
reportGeneration.reportPaths,
reportSourceContact,
summaryDateRange
])
const selectedReport =
generatedReports.find((report) => report.id === selectedReportId) || generatedReports[0] || null
const openReportResult = (): void => {
if (generatedReports[0] && !selectedReportId) setSelectedReportId(generatedReports[0].id)
setReportWorkspaceView('result')
}
const openReportConfigure = (): void => {
setReportWorkspaceView('configure')
}
const handleRegenerateReport = (): void => {
if (selectedReport) {
const source = contacts.find((contact) => contact.md5 === selectedReport.contactId)
if (source) setReportSourceContact(source)
}
setReportWorkspaceView('configure')
}
const handleCopyReportImage = async (
report: GeneratedReportRecord
): Promise<{ success: boolean; error?: string }> => {
if (!report.generatedImage) return { success: false, error: '没有可复制的日报图片' }
return window.api.copyImage(report.generatedImage)
}
const handleRevealReport = async (
report: GeneratedReportRecord
): Promise<{ success: boolean; error?: string }> => {
const filePath = report.pngPath || report.htmlPath
if (!filePath) return { success: false, error: '当前报告缺少文件路径' }
return window.api.revealGroupReport(filePath)
}
const renderPlaceholderPage = (page: Exclude<AppPage, 'archive' | 'report'>): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索',
@@ -826,43 +959,75 @@ function App(): React.ReactElement {
)
const renderReportWorkspace = (): React.ReactElement => (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectContact={handleSelectReportSource}
onOpenSettings={() => setShowSettings(true)}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig}
rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts}
rangeState={reportGeneration.rangeState}
phase={reportGeneration.phase}
error={reportGeneration.error}
generatedImage={reportGeneration.generatedImage}
reportPaths={reportGeneration.reportPaths}
isGenerating={reportGeneration.isGenerating}
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={() => setShowSettings(true)}
onGenerate={() => void reportGeneration.generate()}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
onRetry={() => void reportGeneration.retry()}
/>
</div>
reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
reports={generatedReports}
selectedReportId={selectedReport?.id || null}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectReport={(reportId) => {
setSelectedReportId(reportId)
setSelectedReportImageSize(null)
}}
onCreateReport={openReportConfigure}
onOpenSettings={() => setShowSettings(true)}
/>
<ReportViewer
report={selectedReport}
onBackToConfigure={openReportConfigure}
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
onImageSizeChange={setSelectedReportImageSize}
/>
<ReportSettingsPanel
report={selectedReport}
imageSize={selectedReportImageSize}
onReveal={handleRevealReport}
/>
</div>
) : (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectContact={handleSelectReportSource}
onOpenSettings={() => setShowSettings(true)}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig}
rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts}
rangeState={reportGeneration.rangeState}
phase={reportGeneration.phase}
error={reportGeneration.error}
generatedImage={reportGeneration.generatedImage}
reportPaths={reportGeneration.reportPaths}
isGenerating={reportGeneration.isGenerating}
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={() => setShowSettings(true)}
onGenerate={() => void reportGeneration.generate()}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
onViewResult={openReportResult}
hasReportResult={generatedReports.length > 0}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
onRetry={() => void reportGeneration.retry()}
/>
</div>
)
)
const renderCurrentWorkspace = (): React.ReactElement => {
+480 -1
View File
@@ -2788,7 +2788,19 @@ body {
background: var(--wxex-bg-main);
}
.report-page > * {
.report-center-page {
display: grid;
grid-template-columns: 292px minmax(420px, 1fr) 304px;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--wxex-bg-main);
}
.report-page > *,
.report-center-page > * {
min-height: 0;
min-width: 0;
}
@@ -3284,6 +3296,13 @@ body {
color: #fff;
}
.ai-report-footer button.secondary {
min-width: 92px;
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
}
.ai-report-footer button:disabled {
border-color: var(--wxex-border);
background: #e8ebe9;
@@ -3423,3 +3442,463 @@ body {
opacity: 1;
cursor: default;
}
.report-history-sidebar {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
}
.report-history-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 16px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.report-history-header h2,
.report-settings-panel header h2,
.report-viewer-header h1 {
margin: 0;
color: var(--wxex-text-primary);
}
.report-history-header h2 {
font: 700 18px/24px var(--wxex-font);
}
.report-history-header p,
.report-settings-panel header p,
.report-viewer-header p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-history-header > span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-history-search {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin: 12px 14px 8px;
padding: 0 10px;
height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-history-search svg {
width: 16px;
height: 16px;
}
.report-history-search path,
.report-history-search circle {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.report-history-search input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-history-filters {
display: grid;
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);
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);
}
.report-history-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 0 10px 12px;
}
.report-history-item {
position: relative;
display: flex;
width: 100%;
min-width: 0;
gap: 10px;
padding: 10px 10px 10px 12px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
text-align: left;
cursor: pointer;
font-family: var(--wxex-font);
}
.report-history-item:hover {
background: rgba(255, 255, 255, 0.62);
}
.report-history-item.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-history-item.active::before {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 4px;
background: var(--wxex-brand);
content: '';
}
.report-history-avatar {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font: 700 14px/1 var(--wxex-font);
}
.report-history-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.report-history-text {
display: grid;
min-width: 0;
gap: 2px;
}
.report-history-text b,
.report-history-text small,
.report-history-text em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-history-text b {
color: inherit;
font: 600 13px/18px var(--wxex-font);
}
.report-history-text small,
.report-history-text em {
color: var(--wxex-text-muted);
font: normal 12px/16px var(--wxex-font);
}
.report-history-empty {
display: grid;
gap: 10px;
padding: 18px 8px;
color: var(--wxex-text-muted);
font: 13px/18px var(--wxex-font);
}
.report-history-empty button,
.report-center-empty button {
justify-self: start;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-history-account {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--wxex-border);
}
.report-viewer {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
background: #f2f1ee;
}
.report-viewer-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-viewer-header h1 {
font: 700 20px/27px var(--wxex-font);
}
.report-viewer-toolbar {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.report-viewer-toolbar button,
.report-zoom-bar button,
.report-more-popover button,
.report-settings-section button {
min-height: 32px;
padding: 0 12px;
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-viewer-toolbar button.primary {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-viewer-toolbar button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.72;
}
.report-more-menu {
position: relative;
}
.report-more-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 8;
min-width: 128px;
padding: 6px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-more-popover button {
width: 100%;
border: 0;
text-align: left;
}
.report-viewer-status {
flex: 0 0 auto;
padding: 8px 22px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
font: 12px/18px var(--wxex-font);
}
.report-viewer-stage {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
justify-content: center;
overflow: auto;
padding: 22px;
}
.report-canvas {
align-self: flex-start;
padding: 0;
background: #fff;
box-shadow: 0 8px 24px rgba(32, 39, 36, 0.14);
}
.report-canvas img {
display: block;
max-width: none;
height: auto;
}
.report-zoom-bar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-zoom-bar span {
min-width: 48px;
color: var(--wxex-text-secondary);
text-align: center;
font: 12px/18px var(--wxex-font);
}
.report-center-empty {
display: grid;
align-self: center;
justify-self: center;
max-width: 360px;
gap: 10px;
margin: auto;
color: var(--wxex-text-secondary);
text-align: center;
}
.report-center-empty h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-center-empty p {
margin: 0;
font: 13px/20px var(--wxex-font);
}
.report-center-empty button {
justify-self: center;
}
.report-settings-panel {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--wxex-border);
background: #f7f9f8;
}
.report-settings-panel header {
flex: 0 0 auto;
padding: 20px 18px 14px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-panel header h2 {
font: 700 16px/22px var(--wxex-font);
}
.report-settings-section {
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-section h3 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.report-settings-section p,
.report-settings-section code {
margin: 0 0 10px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-settings-section code {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
padding: 8px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
}
.report-settings-section.muted {
color: var(--wxex-text-muted);
}
.report-export-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.report-export-list div {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-export-list b {
color: var(--wxex-text-primary);
font-weight: 600;
}
@media (max-width: 1120px) {
.report-center-page {
grid-template-columns: 268px minmax(360px, 1fr) 280px;
}
.report-viewer-header {
flex-direction: column;
}
}
@@ -34,6 +34,8 @@ interface AiReportWorkspaceProps {
onCloseResult: () => void
onCopyImage: () => Promise<{ success: boolean; error?: string }>
onRevealReport: () => Promise<{ success: boolean; error?: string }>
onViewResult: () => void
hasReportResult: boolean
}
const rangeLabel = (range: SummaryDateRange): string => {
@@ -72,7 +74,9 @@ export function AiReportWorkspace({
onGenerate,
onCloseResult,
onCopyImage,
onRevealReport
onRevealReport,
onViewResult,
hasReportResult
}: AiReportWorkspaceProps): React.ReactElement {
const [actionStatus, setActionStatus] = useState('')
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
@@ -160,6 +164,9 @@ export function AiReportWorkspace({
<img src={generatedImage} alt="生成的群聊日报" />
</div>
<div className="report-result-actions">
<button type="button" onClick={onViewResult}>
</button>
<button type="button" onClick={handleCopy}>
</button>
@@ -181,6 +188,11 @@ export function AiReportWorkspace({
</span>
<div className="report-footer-actions">
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
{hasReportResult && !isGenerating && (
<button type="button" className="secondary" onClick={onViewResult}>
</button>
)}
<button type="button" disabled={!canGenerate} onClick={onGenerate}>
{isGenerating ? '正在生成日报' : '开始生成日报'}
</button>
@@ -0,0 +1,25 @@
import React from 'react'
interface ReportEmptyStateProps {
title: string
message: string
actionLabel: string
onAction: () => void
}
export function ReportEmptyState({
title,
message,
actionLabel,
onAction
}: ReportEmptyStateProps): React.ReactElement {
return (
<div className="report-center-empty">
<h2>{title}</h2>
<p>{message}</p>
<button type="button" onClick={onAction}>
{actionLabel}
</button>
</div>
)
}
@@ -0,0 +1,60 @@
import React from 'react'
import type { GeneratedReportRecord } from './types'
interface ReportExportStatusProps {
report: GeneratedReportRecord | null
imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
}
export function ReportExportStatus({
report,
imageSize,
onReveal
}: ReportExportStatusProps): React.ReactElement {
const [status, setStatus] = React.useState('')
if (!report) {
return (
<section className="report-settings-section">
<h3></h3>
<p></p>
</section>
)
}
const handleReveal = async (): Promise<void> => {
const result = await onReveal(report)
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
}
return (
<section className="report-settings-section">
<h3></h3>
<div className="report-export-list">
<div>
<span>HTML</span>
<b>{report.htmlStatus === 'ready' ? '已保存' : '缺失'}</b>
</div>
<div>
<span>PNG </span>
<b>{report.pngStatus === 'ready' ? '已保存' : '缺失'}</b>
</div>
{imageSize && (
<div>
<span></span>
<b>
{imageSize.width} x {imageSize.height}
</b>
</div>
)}
</div>
{(report.pngPath || report.htmlPath) && (
<button type="button" onClick={() => void handleReveal()}>
</button>
)}
{status && <p>{status}</p>}
</section>
)
}
@@ -0,0 +1,149 @@
import React, { useMemo, useState } from 'react'
import { AccountSummary } from '../account/AccountSummary'
import type { GeneratedReportRecord } from './types'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface ReportHistorySidebarProps {
reports: GeneratedReportRecord[]
selectedReportId: string | null
selfInfo: SelfInfo | null
dbReady: boolean
onSelectReport: (reportId: string) => void
onCreateReport: () => void
onOpenSettings: () => void
}
type HistoryFilter = 'today' | 'yesterday' | 'week' | 'older'
const DAY_MS = 86400000
const dateKey = (value: string): number => {
const parsed = new Date(value).getTime()
return Number.isFinite(parsed) ? parsed : 0
}
const formatGeneratedAt = (value: string): string => {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
export function ReportHistorySidebar({
reports,
selectedReportId,
selfInfo,
dbReady,
onSelectReport,
onCreateReport,
onOpenSettings
}: ReportHistorySidebarProps): React.ReactElement {
const [keyword, setKeyword] = useState('')
const [filter, setFilter] = useState<HistoryFilter>('today')
const filteredReports = 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
.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
})
.sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt))
}, [filter, keyword, reports])
return (
<aside className="report-history-sidebar">
<div className="report-history-header">
<div>
<h2>AI </h2>
<p></p>
</div>
<span>{reports.length}</span>
</div>
<label className="report-history-search">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="10.5" cy="10.5" r="5.5" />
<path d="m15 15 4 4" />
</svg>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
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>
))}
</div>
<div className="report-history-list">
{filteredReports.length ? (
filteredReports.map((report) => (
<button
key={report.id}
type="button"
className={`report-history-item ${report.id === selectedReportId ? 'active' : ''}`}
onClick={() => onSelectReport(report.id)}
>
<span className="report-history-avatar">
{report.contactAvatar ? (
<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>{report.messageCount} </small>
<em>
HTML {report.htmlStatus === 'ready' ? '已保存' : '缺失'} · PNG{' '}
{report.pngStatus === 'ready' ? '已保存' : '缺失'}
</em>
</span>
</button>
))
) : (
<div className="report-history-empty">
<b></b>
<button type="button" onClick={onCreateReport}>
</button>
</div>
)}
</div>
<div className="report-history-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
</div>
</aside>
)
}
@@ -0,0 +1,83 @@
import React, { useState } from 'react'
import type { GeneratedReportRecord } from './types'
import { ReportExportStatus } from './ReportExportStatus'
interface ReportSettingsPanelProps {
report: GeneratedReportRecord | null
imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
}
const formatGeneratedAt = (value: string): string => {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
export function ReportSettingsPanel({
report,
imageSize,
onReveal
}: ReportSettingsPanelProps): React.ReactElement {
const [copyStatus, setCopyStatus] = useState('')
const path = report?.pngPath || report?.htmlPath || ''
const copyPath = async (): Promise<void> => {
if (!path) return
try {
await navigator.clipboard.writeText(path)
setCopyStatus('文件路径已复制')
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : String(error))
}
}
return (
<aside className="report-settings-panel">
<header>
<h2></h2>
<p></p>
</header>
<section className="report-settings-section">
<h3></h3>
{report ? (
<div className="report-export-list">
<div>
<span></span>
<b>{formatGeneratedAt(report.generatedAt)}</b>
</div>
<div>
<span></span>
<b>{report.messageCount} </b>
</div>
<div>
<span></span>
<b>{report.dateRange}</b>
</div>
</div>
) : (
<p></p>
)}
</section>
<ReportExportStatus report={report} imageSize={imageSize} onReveal={onReveal} />
<section className="report-settings-section">
<h3></h3>
{path ? (
<>
<code>{path}</code>
<button type="button" onClick={() => void copyPath()}>
</button>
{copyStatus && <p>{copyStatus}</p>}
</>
) : (
<p></p>
)}
</section>
<section className="report-settings-section muted">
<h3></h3>
<p></p>
</section>
</aside>
)
}
@@ -0,0 +1,62 @@
import React, { useEffect, useRef, useState } from 'react'
interface ReportToolbarProps {
canCopyImage: boolean
canReveal: boolean
onRegenerate: () => void
onCopyImage: () => void
onReveal: () => void
}
export function ReportToolbar({
canCopyImage,
canReveal,
onRegenerate,
onCopyImage,
onReveal
}: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!moreOpen) return
const close = (event: PointerEvent): void => {
if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false)
}
window.addEventListener('pointerdown', close)
return () => window.removeEventListener('pointerdown', close)
}, [moreOpen])
return (
<div className="report-viewer-toolbar">
<button type="button" onClick={onRegenerate}>
</button>
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}>
</button>
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
</button>
<div className="report-more-menu" ref={menuRef}>
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
</button>
{moreOpen && (
<div className="report-more-popover">
<button
type="button"
disabled={!canReveal}
onClick={() => {
setMoreOpen(false)
onReveal()
}}
>
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,136 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { GeneratedReportRecord } from './types'
import { ReportEmptyState } from './ReportEmptyState'
import { ReportToolbar } from './ReportToolbar'
import { ReportZoomBar } from './ReportZoomBar'
interface ReportViewerProps {
report: GeneratedReportRecord | null
onBackToConfigure: () => void
onRegenerate: () => void
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onImageSizeChange: (size: { width: number; height: number } | null) => void
}
export function ReportViewer({
report,
onBackToConfigure,
onRegenerate,
onCopyImage,
onReveal,
onImageSizeChange
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [status, setStatus] = useState('')
const [imageError, setImageError] = useState('')
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
setStatus('')
setImageError('')
setZoom(1)
setNaturalSize(null)
onImageSizeChange(null)
})
return () => window.cancelAnimationFrame(frame)
}, [onImageSizeChange, report?.id])
const title = useMemo(
() => (report ? `${report.contactName} 群聊日报` : 'AI 群聊日报'),
[report]
)
const fitWidth = (): void => {
const viewport = viewportRef.current
if (!viewport || !naturalSize?.width) return
const nextZoom = Math.min(2, Math.max(0.25, (viewport.clientWidth - 48) / naturalSize.width))
setZoom(Number(nextZoom.toFixed(2)))
}
const handleCopy = async (): Promise<void> => {
if (!report) return
const result = await onCopyImage(report)
setStatus(result.success ? '图片已复制' : result.error || '复制图片失败')
}
const handleReveal = async (): Promise<void> => {
if (!report) return
const result = await onReveal(report)
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
}
if (!report) {
return (
<main className="report-viewer">
<ReportEmptyState
title="尚未生成日报"
message="生成一份 AI 群聊日报后,可以在这里查看本地保存的长图。"
actionLabel="生成新日报"
onAction={onBackToConfigure}
/>
</main>
)
}
return (
<main className="report-viewer">
<header className="report-viewer-header">
<div>
<h1>{title}</h1>
<p>
{report.dateRange} · {report.messageCount} · AI
</p>
</div>
<ReportToolbar
canCopyImage={Boolean(report.generatedImage)}
canReveal={Boolean(report.pngPath || report.htmlPath)}
onRegenerate={onRegenerate}
onCopyImage={() => void handleCopy()}
onReveal={() => void handleReveal()}
/>
</header>
{status && <div className="report-viewer-status">{status}</div>}
<div className="report-viewer-stage" ref={viewportRef}>
{report.generatedImage && !imageError ? (
<div className="report-canvas">
<img
src={report.generatedImage}
alt={title}
style={{
width: naturalSize ? `${Math.round(naturalSize.width * zoom)}px` : undefined
}}
onLoad={(event) => {
const image = event.currentTarget
const size = {
width: image.naturalWidth,
height: image.naturalHeight
}
setNaturalSize(size)
onImageSizeChange(size)
}}
onError={() => {
setImageError('日报图片加载失败')
onImageSizeChange(null)
}}
/>
</div>
) : (
<ReportEmptyState
title={imageError || '暂无 PNG 预览'}
message={
report.htmlPath
? 'HTML 已保存,可以打开文件夹查看报告文件。'
: '当前记录没有可预览的报告文件。'
}
actionLabel="返回配置"
onAction={onBackToConfigure}
/>
)}
</div>
<ReportZoomBar zoom={zoom} onZoomChange={setZoom} onFitWidth={fitWidth} />
</main>
)
}
@@ -0,0 +1,30 @@
import React from 'react'
interface ReportZoomBarProps {
zoom: number
onZoomChange: (zoom: number) => void
onFitWidth: () => void
}
const clampZoom = (value: number): number => Math.min(2, Math.max(0.25, value))
export function ReportZoomBar({
zoom,
onZoomChange,
onFitWidth
}: ReportZoomBarProps): React.ReactElement {
return (
<div className="report-zoom-bar">
<button type="button" onClick={() => onZoomChange(clampZoom(zoom - 0.1))}>
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom + 0.1))}>
</button>
<button type="button" onClick={onFitWidth}>
</button>
</div>
)
}
@@ -0,0 +1,13 @@
import { Contact } from '../../../../shared/types'
export type {
GeneratedReportRecord,
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
} from '../../../../shared/report-history'
export type ReportWorkspaceView = 'configure' | 'result'
export const contactDisplayName = (contact: Contact | null): string =>
contact?.m_nsNickName?.trim() || contact?.m_nsUsrName || '未命名群聊'
+42
View File
@@ -0,0 +1,42 @@
export type ReportAssetStatus = 'ready' | 'missing'
export interface GeneratedReportRecord {
id: string
contactId: string
contactName: string
contactAvatar?: string
dateRange: string
messageCount: number
generatedAt: string
reportDate: string
htmlPath?: string
pngPath?: string
jsonPath?: string
htmlStatus: ReportAssetStatus
pngStatus: ReportAssetStatus
generatedImage?: string
}
export interface SaveGeneratedReportRequest {
contactId: string
contactName: string
contactAvatar?: string
dateRange: string
messageCount: number
generatedAt: string
generatedImage?: string
htmlPath?: string
pngPath?: string
}
export interface ReportHistoryResult {
success: boolean
reports?: GeneratedReportRecord[]
error?: string
}
export interface SaveGeneratedReportResult {
success: boolean
record?: GeneratedReportRecord
error?: string
}