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 { parseMessageContent } from './message-parser'
|
||||||
import { ImageDecryptService } from './image-decrypt-service'
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import { 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 { DatabaseKeyStore } from './database-key-store'
|
||||||
import { KeyServiceMac } from './key-service-mac'
|
import { KeyServiceMac } from './key-service-mac'
|
||||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||||
@@ -333,6 +335,14 @@ app.whenReady().then(async () => {
|
|||||||
return exportGroupReport(request)
|
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) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
try {
|
try {
|
||||||
shell.showItemInFolder(filePath)
|
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 { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
import { Contact, Message } from '../shared/types'
|
import { Contact, Message } from '../shared/types'
|
||||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||||
|
import {
|
||||||
|
ReportHistoryResult,
|
||||||
|
SaveGeneratedReportRequest,
|
||||||
|
SaveGeneratedReportResult
|
||||||
|
} from '../shared/report-history'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -84,6 +89,10 @@ declare global {
|
|||||||
md5?: string
|
md5?: string
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||||
|
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||||
|
saveGeneratedReport: (
|
||||||
|
request: SaveGeneratedReportRequest
|
||||||
|
) => Promise<SaveGeneratedReportResult>
|
||||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||||
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||||
autoGetDbKey: () => Promise<{
|
autoGetDbKey: () => Promise<{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
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
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -36,6 +37,9 @@ const api = {
|
|||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
ipcRenderer.invoke('report:export', request),
|
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),
|
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
|
|||||||
+202
-37
@@ -5,8 +5,13 @@ import { SettingsPanel } from './components/SettingsPanel'
|
|||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
import { AppPage } from './components/layout/navigation'
|
import { AppPage } from './components/layout/navigation'
|
||||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
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 { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
||||||
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
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 {
|
import {
|
||||||
AiModelConfig,
|
AiModelConfig,
|
||||||
useGroupReportGeneration
|
useGroupReportGeneration
|
||||||
@@ -167,6 +172,13 @@ function App(): React.ReactElement {
|
|||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
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 [reportNotice, setReportNotice] = useState('')
|
||||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||||
@@ -192,6 +204,24 @@ function App(): React.ReactElement {
|
|||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
modelConfig: aiModelConfig
|
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> =>
|
const waitForPaint = (): Promise<void> =>
|
||||||
new Promise((resolve) => window.setTimeout(resolve, 80))
|
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 handleLogin = async (keyInput?: string): Promise<void> => {
|
||||||
const keyToUse = keyInput || dbKey
|
const keyToUse = keyInput || dbKey
|
||||||
if (!keyToUse) return
|
if (!keyToUse) return
|
||||||
@@ -746,6 +781,10 @@ function App(): React.ReactElement {
|
|||||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||||
setReportSourceContact(selectedContact)
|
setReportSourceContact(selectedContact)
|
||||||
}
|
}
|
||||||
|
if (page === 'report') {
|
||||||
|
setReportWorkspaceView('result')
|
||||||
|
if (!selectedReportId && generatedReports[0]) setSelectedReportId(generatedReports[0].id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenReportWorkspace = (): void => {
|
const handleOpenReportWorkspace = (): void => {
|
||||||
@@ -761,6 +800,7 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
setReportNotice('')
|
setReportNotice('')
|
||||||
setReportSourceContact(selectedContact)
|
setReportSourceContact(selectedContact)
|
||||||
|
setReportWorkspaceView('configure')
|
||||||
setActivePage('report')
|
setActivePage('report')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +817,99 @@ function App(): React.ReactElement {
|
|||||||
localStorage.setItem('ai_model', aiModelConfig.model)
|
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 renderPlaceholderPage = (page: Exclude<AppPage, 'archive' | 'report'>): React.ReactElement => {
|
||||||
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
|
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
|
||||||
search: '检索',
|
search: '检索',
|
||||||
@@ -826,43 +959,75 @@ function App(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderReportWorkspace = (): React.ReactElement => (
|
const renderReportWorkspace = (): React.ReactElement => (
|
||||||
<div className="report-page">
|
reportWorkspaceView === 'result' ? (
|
||||||
<ReportSourceSidebar
|
<div className="report-center-page">
|
||||||
contacts={contacts}
|
<ReportHistorySidebar
|
||||||
selectedContact={reportSourceContact}
|
reports={generatedReports}
|
||||||
selfInfo={selfInfo}
|
selectedReportId={selectedReport?.id || null}
|
||||||
dbReady={isAuthenticated}
|
selfInfo={selfInfo}
|
||||||
onSelectContact={handleSelectReportSource}
|
dbReady={isAuthenticated}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onSelectReport={(reportId) => {
|
||||||
/>
|
setSelectedReportId(reportId)
|
||||||
<AiReportWorkspace
|
setSelectedReportImageSize(null)
|
||||||
sourceContact={reportSourceContact}
|
}}
|
||||||
summaryDateRange={summaryDateRange}
|
onCreateReport={openReportConfigure}
|
||||||
summaryMessageTypes={summaryMessageTypes}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
modelConfig={aiModelConfig}
|
/>
|
||||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
<ReportViewer
|
||||||
reportMessageCount={reportGeneration.reportMessages.length}
|
report={selectedReport}
|
||||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
onBackToConfigure={openReportConfigure}
|
||||||
rangeState={reportGeneration.rangeState}
|
onRegenerate={handleRegenerateReport}
|
||||||
phase={reportGeneration.phase}
|
onCopyImage={handleCopyReportImage}
|
||||||
error={reportGeneration.error}
|
onReveal={handleRevealReport}
|
||||||
generatedImage={reportGeneration.generatedImage}
|
onImageSizeChange={setSelectedReportImageSize}
|
||||||
reportPaths={reportGeneration.reportPaths}
|
/>
|
||||||
isGenerating={reportGeneration.isGenerating}
|
<ReportSettingsPanel
|
||||||
onSummaryDateRangeChange={setSummaryDateRange}
|
report={selectedReport}
|
||||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
imageSize={selectedReportImageSize}
|
||||||
onOpenModelSettings={() => setShowSettings(true)}
|
onReveal={handleRevealReport}
|
||||||
onGenerate={() => void reportGeneration.generate()}
|
/>
|
||||||
onCloseResult={reportGeneration.closeResult}
|
</div>
|
||||||
onCopyImage={reportGeneration.copyImage}
|
) : (
|
||||||
onRevealReport={reportGeneration.revealReport}
|
<div className="report-page">
|
||||||
/>
|
<ReportSourceSidebar
|
||||||
<ReportTaskStatusPanel
|
contacts={contacts}
|
||||||
phase={reportGeneration.phase}
|
selectedContact={reportSourceContact}
|
||||||
error={reportGeneration.error}
|
selfInfo={selfInfo}
|
||||||
onRetry={() => void reportGeneration.retry()}
|
dbReady={isAuthenticated}
|
||||||
/>
|
onSelectContact={handleSelectReportSource}
|
||||||
</div>
|
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 => {
|
const renderCurrentWorkspace = (): React.ReactElement => {
|
||||||
|
|||||||
@@ -2788,7 +2788,19 @@ body {
|
|||||||
background: var(--wxex-bg-main);
|
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-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -3284,6 +3296,13 @@ body {
|
|||||||
color: #fff;
|
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 {
|
.ai-report-footer button:disabled {
|
||||||
border-color: var(--wxex-border);
|
border-color: var(--wxex-border);
|
||||||
background: #e8ebe9;
|
background: #e8ebe9;
|
||||||
@@ -3423,3 +3442,463 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
cursor: default;
|
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
|
onCloseResult: () => void
|
||||||
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
||||||
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
onViewResult: () => void
|
||||||
|
hasReportResult: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeLabel = (range: SummaryDateRange): string => {
|
const rangeLabel = (range: SummaryDateRange): string => {
|
||||||
@@ -72,7 +74,9 @@ export function AiReportWorkspace({
|
|||||||
onGenerate,
|
onGenerate,
|
||||||
onCloseResult,
|
onCloseResult,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
onRevealReport
|
onRevealReport,
|
||||||
|
onViewResult,
|
||||||
|
hasReportResult
|
||||||
}: AiReportWorkspaceProps): React.ReactElement {
|
}: AiReportWorkspaceProps): React.ReactElement {
|
||||||
const [actionStatus, setActionStatus] = useState('')
|
const [actionStatus, setActionStatus] = useState('')
|
||||||
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
|
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
|
||||||
@@ -160,6 +164,9 @@ export function AiReportWorkspace({
|
|||||||
<img src={generatedImage} alt="生成的群聊日报" />
|
<img src={generatedImage} alt="生成的群聊日报" />
|
||||||
</div>
|
</div>
|
||||||
<div className="report-result-actions">
|
<div className="report-result-actions">
|
||||||
|
<button type="button" onClick={onViewResult}>
|
||||||
|
查看生成结果
|
||||||
|
</button>
|
||||||
<button type="button" onClick={handleCopy}>
|
<button type="button" onClick={handleCopy}>
|
||||||
复制图片
|
复制图片
|
||||||
</button>
|
</button>
|
||||||
@@ -181,6 +188,11 @@ export function AiReportWorkspace({
|
|||||||
</span>
|
</span>
|
||||||
<div className="report-footer-actions">
|
<div className="report-footer-actions">
|
||||||
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
|
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
|
||||||
|
{hasReportResult && !isGenerating && (
|
||||||
|
<button type="button" className="secondary" onClick={onViewResult}>
|
||||||
|
查看结果
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" disabled={!canGenerate} onClick={onGenerate}>
|
<button type="button" disabled={!canGenerate} onClick={onGenerate}>
|
||||||
{isGenerating ? '正在生成日报' : '开始生成日报'}
|
{isGenerating ? '正在生成日报' : '开始生成日报'}
|
||||||
</button>
|
</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