diff --git a/src/main/index.ts b/src/main/index.ts index e44f396..c65bc2d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts new file mode 100644 index 0000000..c8b1a02 --- /dev/null +++ b/src/main/report-history-service.ts @@ -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 => { + if (!filePath) return false + try { + const stat = await fs.stat(filePath) + return stat.isFile() + } catch { + return false + } +} + +const fileStatus = async (filePath?: string): Promise => + (await exists(filePath)) ? 'ready' : 'missing' + +const readPngAsDataUrl = async (filePath?: string): Promise => { + 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 => { + 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 => { + 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 { + 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 { + 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) } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 2642c2e..55ebffe 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + listGeneratedReports: () => Promise + saveGeneratedReport: ( + request: SaveGeneratedReportRequest + ) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> autoGetDbKey: () => Promise<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index fe222f8..42c6288 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc828ff..8e295f7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('archive') const [reportSourceContact, setReportSourceContact] = useState(null) + const [reportWorkspaceView, setReportWorkspaceView] = useState('result') + const [generatedReports, setGeneratedReports] = useState([]) + const [selectedReportId, setSelectedReportId] = useState(null) + const [selectedReportImageSize, setSelectedReportImageSize] = useState<{ + width: number + height: number + } | null>(null) const [reportNotice, setReportNotice] = useState('') const [summaryDateRange, setSummaryDateRange] = useState('today') const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) @@ -192,6 +204,24 @@ function App(): React.ReactElement { summaryMessageTypes, modelConfig: aiModelConfig }) + const lastCapturedReportKeyRef = React.useRef('') + + const loadGeneratedReports = React.useCallback(async (): Promise => { + 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 => 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 => { 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 => { + 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): React.ReactElement => { const labels: Record, string> = { search: '检索', @@ -826,43 +959,75 @@ function App(): React.ReactElement { ) const renderReportWorkspace = (): React.ReactElement => ( -
- setShowSettings(true)} - /> - setShowSettings(true)} - onGenerate={() => void reportGeneration.generate()} - onCloseResult={reportGeneration.closeResult} - onCopyImage={reportGeneration.copyImage} - onRevealReport={reportGeneration.revealReport} - /> - void reportGeneration.retry()} - /> -
+ reportWorkspaceView === 'result' ? ( +
+ { + setSelectedReportId(reportId) + setSelectedReportImageSize(null) + }} + onCreateReport={openReportConfigure} + onOpenSettings={() => setShowSettings(true)} + /> + + +
+ ) : ( +
+ setShowSettings(true)} + /> + setShowSettings(true)} + onGenerate={() => void reportGeneration.generate()} + onCloseResult={reportGeneration.closeResult} + onCopyImage={reportGeneration.copyImage} + onRevealReport={reportGeneration.revealReport} + onViewResult={openReportResult} + hasReportResult={generatedReports.length > 0} + /> + void reportGeneration.retry()} + /> +
+ ) ) const renderCurrentWorkspace = (): React.ReactElement => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 750ea18..2fd858c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; + } +} diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx index 3b55b88..4d72d15 100644 --- a/src/renderer/src/components/reports/AiReportWorkspace.tsx +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -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({ 生成的群聊日报
+ @@ -181,6 +188,11 @@ export function AiReportWorkspace({
{disabledReason && !isGenerating && {disabledReason}} + {hasReportResult && !isGenerating && ( + + )} diff --git a/src/renderer/src/components/reports/ReportEmptyState.tsx b/src/renderer/src/components/reports/ReportEmptyState.tsx new file mode 100644 index 0000000..6a0ffaa --- /dev/null +++ b/src/renderer/src/components/reports/ReportEmptyState.tsx @@ -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 ( +
+

{title}

+

{message}

+ +
+ ) +} diff --git a/src/renderer/src/components/reports/ReportExportStatus.tsx b/src/renderer/src/components/reports/ReportExportStatus.tsx new file mode 100644 index 0000000..4bee230 --- /dev/null +++ b/src/renderer/src/components/reports/ReportExportStatus.tsx @@ -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 ( +
+

导出状态

+

尚未选择报告。

+
+ ) + } + + const handleReveal = async (): Promise => { + const result = await onReveal(report) + setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败') + } + + return ( +
+

导出状态

+
+
+ HTML + {report.htmlStatus === 'ready' ? '已保存' : '缺失'} +
+
+ PNG 长图 + {report.pngStatus === 'ready' ? '已保存' : '缺失'} +
+ {imageSize && ( +
+ 图片尺寸 + + {imageSize.width} x {imageSize.height} + +
+ )} +
+ {(report.pngPath || report.htmlPath) && ( + + )} + {status &&

{status}

} +
+ ) +} diff --git a/src/renderer/src/components/reports/ReportHistorySidebar.tsx b/src/renderer/src/components/reports/ReportHistorySidebar.tsx new file mode 100644 index 0000000..2a7627d --- /dev/null +++ b/src/renderer/src/components/reports/ReportHistorySidebar.tsx @@ -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('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 ( + + ) +} diff --git a/src/renderer/src/components/reports/ReportSettingsPanel.tsx b/src/renderer/src/components/reports/ReportSettingsPanel.tsx new file mode 100644 index 0000000..c1ed844 --- /dev/null +++ b/src/renderer/src/components/reports/ReportSettingsPanel.tsx @@ -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 => { + if (!path) return + try { + await navigator.clipboard.writeText(path) + setCopyStatus('文件路径已复制') + } catch (error) { + setCopyStatus(error instanceof Error ? error.message : String(error)) + } + } + + return ( + + ) +} diff --git a/src/renderer/src/components/reports/ReportToolbar.tsx b/src/renderer/src/components/reports/ReportToolbar.tsx new file mode 100644 index 0000000..a7a57aa --- /dev/null +++ b/src/renderer/src/components/reports/ReportToolbar.tsx @@ -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(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 ( +
+ + + +
+ + {moreOpen && ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ReportViewer.tsx b/src/renderer/src/components/reports/ReportViewer.tsx new file mode 100644 index 0000000..dc2cb67 --- /dev/null +++ b/src/renderer/src/components/reports/ReportViewer.tsx @@ -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(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 => { + if (!report) return + const result = await onCopyImage(report) + setStatus(result.success ? '图片已复制' : result.error || '复制图片失败') + } + + const handleReveal = async (): Promise => { + if (!report) return + const result = await onReveal(report) + setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败') + } + + if (!report) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+

{title}

+

+ {report.dateRange} · 基于 {report.messageCount} 条消息生成 · AI 生成内容,请核对重要信息 +

+
+ void handleCopy()} + onReveal={() => void handleReveal()} + /> +
+ {status &&
{status}
} +
+ {report.generatedImage && !imageError ? ( +
+ {title} { + const image = event.currentTarget + const size = { + width: image.naturalWidth, + height: image.naturalHeight + } + setNaturalSize(size) + onImageSizeChange(size) + }} + onError={() => { + setImageError('日报图片加载失败') + onImageSizeChange(null) + }} + /> +
+ ) : ( + + )} +
+ +
+ ) +} diff --git a/src/renderer/src/components/reports/ReportZoomBar.tsx b/src/renderer/src/components/reports/ReportZoomBar.tsx new file mode 100644 index 0000000..ddbe6a1 --- /dev/null +++ b/src/renderer/src/components/reports/ReportZoomBar.tsx @@ -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 ( +
+ + {Math.round(zoom * 100)}% + + +
+ ) +} diff --git a/src/renderer/src/components/reports/types.ts b/src/renderer/src/components/reports/types.ts new file mode 100644 index 0000000..897e5ea --- /dev/null +++ b/src/renderer/src/components/reports/types.ts @@ -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 || '未命名群聊' diff --git a/src/shared/report-history.ts b/src/shared/report-history.ts new file mode 100644 index 0000000..f94d6e9 --- /dev/null +++ b/src/shared/report-history.ts @@ -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 +}