mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 实现 AI 日报历史资产化
This commit is contained in:
+11
-1
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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<{
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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 || '未命名群聊'
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user