feat: 完善定时日报

This commit is contained in:
Wxw-Gu
2026-08-27 19:03:07 +08:00
parent 949e6bc868
commit 7cf043e3c3
13 changed files with 1866 additions and 109 deletions
+51 -10
View File
@@ -2,6 +2,12 @@ import type { Contact, Message } from '../../shared/types'
import { exportGroupReport } from '../group-report-service'
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType
} from '../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { resolveMemberName } from '../../shared/member-names'
import {
buildGroupReportInput,
getSummaryDateRange,
@@ -17,6 +23,10 @@ const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange | 'recent24h'
messageTypes?: ScheduledReportMessageType[]
templateId?: SelectableReportTemplateId
memberNameMode?: ScheduledReportMemberNameMode
timeoutSeconds?: number
}
export interface AgentGroupReportResult {
@@ -49,12 +59,33 @@ export async function generateAgentGroupReport(
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
const messageTypeMap: Record<ScheduledReportMessageType, string[]> = {
text: ['普通文本'],
image: ['图片'],
sticker: ['表情包'],
video: ['视频'],
voice: ['语音'],
share: ['分享消息', '名片', '位置', '通话'],
system: ['系统消息']
}
const selectedTypes = new Set(
(request.messageTypes?.length
? request.messageTypes
: (Object.keys(messageTypeMap) as ScheduledReportMessageType[])
).flatMap((type) => messageTypeMap[type] || [])
)
messages = messages.filter((message) => selectedTypes.has(message.type))
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) {
const members = new Map(
snapshot.members.map((member) => [
member.wxid,
{ name: member.nickname, avatar: member.avatar }
{
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
avatar: member.avatar
}
])
)
messages = messages.map((message) => {
@@ -67,10 +98,13 @@ export async function generateAgentGroupReport(
}
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
const ai = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
])
const ai = await aiProvider.chat(
[
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
)
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
parseGroupDailyReport(
@@ -85,10 +119,13 @@ export async function generateAgentGroupReport(
try {
report = parseReport(ai.data)
} catch (parseError) {
const repaired = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: ai.data }
])
const repaired = await aiProvider.chat(
[
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: ai.data }
],
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
)
if (!repaired.success || !repaired.data) {
const cause = parseError instanceof Error ? parseError.message : String(parseError)
return {
@@ -105,7 +142,11 @@ export async function generateAgentGroupReport(
}
}
}
const exported = await exportGroupReport({ report, metadata: input.metadata })
const exported = await exportGroupReport({
report,
metadata: input.metadata,
templateId: request.templateId
})
if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' }
}
+40 -3
View File
@@ -9,6 +9,8 @@ import type {
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportMessageType,
ScheduledReportMemberNameMode,
ScheduledReportRange,
ScheduledReportResult,
ScheduledReportTask,
@@ -41,7 +43,7 @@ const defaultDependencies = (): ScheduledReportDependencies => ({
isDatabaseReady: () => isChatReady()
})
const rangeValues = new Set<ScheduledReportRange>(['yesterday', 'recent24h'])
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
export function validateScheduleTime(value: string): boolean {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
@@ -112,6 +114,10 @@ export class ScheduledReportService {
group: values.group,
scheduleTime: values.scheduleTime,
reportRange: values.reportRange,
messageTypes: values.messageTypes,
templateId: values.templateId,
memberNameMode: values.memberNameMode,
timeoutSeconds: values.timeoutSeconds,
target: values.target,
enabled: values.enabled,
createdAt: now.toISOString(),
@@ -286,7 +292,14 @@ export class ScheduledReportService {
}
let generated: Awaited<ReturnType<typeof this.deps.generateReport>>
try {
generated = await this.deps.generateReport({ group: task.group, range: task.reportRange })
generated = await this.deps.generateReport({
group: task.group,
range: task.reportRange,
messageTypes: task.messageTypes,
templateId: task.templateId,
memberNameMode: task.memberNameMode,
timeoutSeconds: task.timeoutSeconds
})
} catch (error) {
return finish(
'failed',
@@ -343,6 +356,10 @@ export class ScheduledReportService {
group: string
scheduleTime: string
reportRange: ScheduledReportRange
messageTypes: ScheduledReportMessageType[]
templateId: ScheduledReportTask['templateId']
memberNameMode: ScheduledReportMemberNameMode
timeoutSeconds: number
target: string
enabled: boolean
}> {
@@ -350,6 +367,15 @@ export class ScheduledReportService {
const group = String(input.group || '').trim()
const scheduleTime = String(input.scheduleTime || '').trim()
const reportRange = input.reportRange || 'yesterday'
const messageTypes: ScheduledReportMessageType[] = input.messageTypes?.length
? input.messageTypes
: ['text']
const templateId = input.templateId || 'v1'
const memberNameMode = input.memberNameMode || 'groupNickname'
const timeoutSeconds = Math.max(
30,
Math.min(1800, Math.round(Number(input.timeoutSeconds) || 300))
)
const target = String(input.target || group).trim()
if (!name) return { success: false, error: '日报名称不能为空' }
if (!group) return { success: false, error: '微信群不能为空' }
@@ -358,7 +384,18 @@ export class ScheduledReportService {
if (!rangeValues.has(reportRange)) return { success: false, error: '日报范围不受支持' }
return {
success: true,
data: { name, group, scheduleTime, reportRange, target, enabled: input.enabled !== false }
data: {
name,
group,
scheduleTime,
reportRange,
messageTypes,
templateId,
memberNameMode,
timeoutSeconds,
target,
enabled: input.enabled !== false
}
}
}
+147 -94
View File
@@ -18,6 +18,7 @@ import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
@@ -248,6 +249,7 @@ function App(): React.ReactElement {
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [reportSection, setReportSection] = useState<'today' | 'scheduled'>('today')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
@@ -1461,6 +1463,7 @@ function App(): React.ReactElement {
if (page === 'report') {
setReportWorkspaceView('result')
setSelectedReportId(null)
setReportSection('today')
}
}
@@ -1484,6 +1487,11 @@ function App(): React.ReactElement {
setActivePage('settings')
}
const openWechatSendSettings = (): void => {
setSettingsCategory('wechat-send')
setActivePage('settings')
}
const dismissFirstUseWelcome = (): void => {
try {
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
@@ -1751,102 +1759,146 @@ function App(): React.ReactElement {
</div>
)
const renderReportWorkspace = (): React.ReactElement =>
reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
reports={generatedReports}
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectReport={openReport}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
onOpenSettings={openSettings}
/>
<ReportViewer
report={selectedReport}
hasReports={generatedReports.length > 0}
onBackToConfigure={openReportConfigure}
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
sendTarget={selectedReportContact}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
const renderReportWorkspace = (): React.ReactElement => (
<div className="report-workspace-shell">
<div className="report-workspace-tabs" role="tablist" aria-label="日报类型">
<button
type="button"
role="tab"
aria-selected={reportSection === 'today'}
className={reportSection === 'today' ? 'active' : ''}
onClick={() => setReportSection('today')}
>
今日日报
</button>
<button
type="button"
role="tab"
aria-selected={reportSection === 'scheduled'}
aria-disabled={runtimePlatform !== 'darwin'}
className={`${reportSection === 'scheduled' ? 'active' : ''} ${runtimePlatform !== 'darwin' ? 'unsupported' : ''}`}
onClick={() => {
if (runtimePlatform !== 'darwin') {
toast({ description: '定时日报目前仅支持 macOS。', duration: 3200 })
return
}
setReportSection('scheduled')
}}
>
定时日报{runtimePlatform !== 'darwin' && <small>仅 macOS</small>}
</button>
</div>
) : (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectContact={handleSelectReportSource}
onOpenSettings={openSettings}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={reportTextModelConfig}
visionModelConfig={aiVisionModelConfig}
textModelOptions={reportTextModelOptions}
visionModelOptions={reportVisionModelOptions}
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={openModelSettings}
onTextModelChange={(model) => {
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
setReportTextModelConfig(model)
}}
onVisionModelChange={(model) => {
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
setAiVisionModelConfig(model)
}}
onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
}}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
onViewResult={openReportResult}
hasReportResult={generatedReports.length > 0}
templateId={reportGeneration.templateId}
onTemplateIdChange={reportGeneration.setTemplateId}
memberNamePreference={reportGeneration.memberNamePreference}
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
preparationProgress={reportGeneration.preparationProgress}
imageInsightSummary={reportGeneration.imageInsightSummary}
canRetryModelStep={reportGeneration.canRetryModelStep}
currentModel={reportTextModelConfig}
onRetry={(model) => void reportGeneration.retry(model)}
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
/>
<div className="report-workspace-body">
{reportSection === 'scheduled' ? (
<ScheduledReportsWorkspace
contacts={contacts}
platformSupported={runtimePlatform === 'darwin'}
onOpenWechatSettings={openWechatSendSettings}
onOpenModelSettings={openModelSettings}
onNotice={(message, variant) =>
toast({ description: message, variant, duration: 3200 })
}
/>
) : reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
reports={generatedReports}
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectReport={openReport}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
onOpenSettings={openSettings}
/>
<ReportViewer
report={selectedReport}
hasReports={generatedReports.length > 0}
onBackToConfigure={openReportConfigure}
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
sendTarget={selectedReportContact}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
) : (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onSelectContact={handleSelectReportSource}
onOpenSettings={openSettings}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={reportTextModelConfig}
visionModelConfig={aiVisionModelConfig}
textModelOptions={reportTextModelOptions}
visionModelOptions={reportVisionModelOptions}
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={openModelSettings}
onTextModelChange={(model) => {
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
setReportTextModelConfig(model)
}}
onVisionModelChange={(model) => {
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
setAiVisionModelConfig(model)
}}
onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
}}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
onViewResult={openReportResult}
hasReportResult={generatedReports.length > 0}
templateId={reportGeneration.templateId}
onTemplateIdChange={reportGeneration.setTemplateId}
memberNamePreference={reportGeneration.memberNamePreference}
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
preparationProgress={reportGeneration.preparationProgress}
imageInsightSummary={reportGeneration.imageInsightSummary}
canRetryModelStep={reportGeneration.canRetryModelStep}
currentModel={reportTextModelConfig}
onRetry={(model) => void reportGeneration.retry(model)}
onContinueAfterImageFailures={() =>
void reportGeneration.continueAfterImageFailures()
}
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
/>
</div>
)}
</div>
)
</div>
)
const renderCurrentWorkspace = (): React.ReactElement => {
switch (activePage) {
@@ -1911,6 +1963,7 @@ function App(): React.ReactElement {
}}
onNotice={setReportNotice}
onOpenSettings={openSettings}
onOpenTextToSpeechSettings={openTextToSpeechSettings}
onAppearanceChange={handleAppearanceChange}
onSwitchAccount={handleSwitchAccount}
/>
@@ -0,0 +1,934 @@
import * as React from 'react'
import type { Contact } from '../../../../shared/types'
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportMessageType,
ScheduledReportMemberNameMode,
ScheduledReportRange,
ScheduledReportTask
} from '../../../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Switch,
Checkbox,
SegmentedControl,
SegmentedControlItem
} from '../ui'
import { contactDisplayName } from './types'
import {
SUMMARY_DATE_OPTIONS,
SUMMARY_TYPE_OPTIONS,
getSummaryDateRange,
type SummaryDateRange
} from '../../utils/group-report'
import { REPORT_TEMPLATES, DEFAULT_REPORT_TEMPLATE } from '../../../../shared/report-templates'
type ReportDialogMode = 'create' | 'edit'
interface ScheduledReportsWorkspaceProps {
contacts: Contact[]
platformSupported?: boolean
onOpenWechatSettings: () => void
onOpenModelSettings?: () => void
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
}
const rangeLabel = (range: ScheduledReportRange): string =>
range === 'recent24h'
? '最近24小时'
: range === '7days'
? '近 7 天'
: range === 'today'
? '今天'
: '昨天'
const isScheduledReportRange = (value: unknown): value is ScheduledReportRange =>
value === 'today' || value === 'yesterday' || value === '7days' || value === 'recent24h'
const allMessageTypes: ScheduledReportMessageType[] = SUMMARY_TYPE_OPTIONS.map(
(option) => option.value
)
const reportRangeForUi = (range: ScheduledReportRange): SummaryDateRange =>
range === '7days' || range === 'yesterday' ? range : 'today'
const messageTypeCounts = (
messages: Array<{ type: string }>
): Record<ScheduledReportMessageType, number> => {
const counts = Object.fromEntries(allMessageTypes.map((type) => [type, 0])) as Record<
ScheduledReportMessageType,
number
>
for (const message of messages) {
const option = SUMMARY_TYPE_OPTIONS.find((item) => item.messageTypes.includes(message.type))
if (option) counts[option.value] += 1
}
return counts
}
const formatDateTime = (value?: string): string => {
if (!value) return '尚未执行'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '时间未知'
const now = new Date()
const dayLabel =
date.toDateString() === now.toDateString() ? '今天' : date.toLocaleDateString('zh-CN')
return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
}
const capabilityCopy = (capability: PersonalWechatSendCapability | null): string => {
if (!capability) return '正在检查微信发送能力…'
switch (capability.status) {
case 'ready':
return '✓ 微信发送能力已就绪'
case 'unsupported':
return '微信消息发送目前仅支持 macOS'
case 'needs_binding':
case 'unconfigured':
return '请先绑定个人微信'
case 'needs_verification':
return '请先完成微信消息能力检测'
default:
return '微信发送能力异常'
}
}
const capabilityTone = (capability: PersonalWechatSendCapability | null): string => {
if (!capability) return 'border-border-subtle bg-muted/30 text-muted-foreground'
if (capability.status === 'ready') return 'border-border-subtle bg-muted/20 text-foreground'
return 'border-border-subtle bg-muted/20 text-muted-foreground'
}
const readableError = (error?: string): string => {
const value = String(error || '')
if (value.includes('wechat_not_ready') || value.includes('仅支持 macOS')) {
if (value.includes('unsupported') || value.includes('仅支持'))
return '微信消息发送目前仅支持 macOS'
if (value.includes('needs_binding') || value.includes('unconfigured')) return '请先绑定个人微信'
if (value.includes('needs_verification')) return '请先完成微信消息能力检测'
return '微信发送能力不可用,请检查微信连接。'
}
if (value.includes('wechat_send_failed')) return '微信发送失败,请检查微信连接。'
if (value.includes('report_generation_failed')) return '日报生成失败,请稍后重试。'
return value || '操作失败,请稍后重试。'
}
const executionLabel = (execution: ScheduledReportExecution): string => {
if (execution.status === 'running') return '生成中 · 待发送'
if (execution.status === 'success') return '已生成并发送'
if (execution.error?.includes('report_generation_failed')) return '日报生成失败'
if (execution.error?.includes('wechat_not_ready')) return '微信发送能力不可用'
return '微信发送失败'
}
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
const name = contactDisplayName(contact)
return (
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-sm font-semibold text-primary">
{contact.avatar ? (
<img
src={contact.avatar}
alt={name}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
name.slice(0, 1)
)}
</span>
)
}
function ScheduledReportDialog({
open,
mode,
task,
contacts,
busy,
onOpenModelSettings,
onOpenChange,
onSubmit
}: {
open: boolean
mode: ReportDialogMode
task: ScheduledReportTask | null
contacts: Contact[]
busy: boolean
onOpenModelSettings?: () => void
onOpenChange: (open: boolean) => void
onSubmit: (input: ScheduledReportCreateInput, taskId?: string) => Promise<void>
}): React.ReactElement {
const groups = React.useMemo(
() => contacts.filter((contact) => contact.type === 'group'),
[contacts]
)
const [keyword, setKeyword] = React.useState('')
const [selectedId, setSelectedId] = React.useState('')
const [name, setName] = React.useState('')
const [scheduleTime, setScheduleTime] = React.useState('09:00')
const [reportRange, setReportRange] = React.useState<ScheduledReportRange>('yesterday')
const [selectedMessageTypes, setSelectedMessageTypes] =
React.useState<ScheduledReportMessageType[]>(allMessageTypes)
const [templateId, setTemplateId] = React.useState<SelectableReportTemplateId>('v1')
const [memberNameMode, setMemberNameMode] =
React.useState<ScheduledReportMemberNameMode>('groupNickname')
const [timeoutSeconds, setTimeoutSeconds] = React.useState(300)
const [rangeMessages, setRangeMessages] = React.useState<Array<{ type: string }>>([])
const [rangeLoading, setRangeLoading] = React.useState(false)
const [memberCount, setMemberCount] = React.useState<number | null>(null)
const [memberCountAvailable, setMemberCountAvailable] = React.useState(true)
const selected = groups.find(
(contact) => contact.md5 === selectedId || contact.m_nsUsrName === selectedId
)
React.useEffect(() => {
if (!open) return
setKeyword('')
setSelectedId(task?.target || task?.group || groups[0]?.md5 || '')
setName(task?.name || '')
setScheduleTime(task?.scheduleTime || '09:00')
const persistedRange = task?.reportRange
setReportRange(isScheduledReportRange(persistedRange) ? persistedRange : 'yesterday')
setSelectedMessageTypes(task?.messageTypes?.length ? task.messageTypes : allMessageTypes)
setTemplateId(task?.templateId || 'v1')
setMemberNameMode(task?.memberNameMode || 'groupNickname')
setTimeoutSeconds(task?.timeoutSeconds || 300)
setRangeMessages([])
setMemberCount(null)
setMemberCountAvailable(true)
}, [groups, open, task])
React.useEffect(() => {
if (!open || !selected) return
setRangeLoading(true)
const loadRange = async (): Promise<void> => {
try {
const range =
reportRange === 'recent24h' ? null : getSummaryDateRange(reportRangeForUi(reportRange))
const now = Math.floor(Date.now() / 1000)
const messages = await window.api.getMessages(
selected.md5,
range?.startTime ?? now - 86400,
range?.endTime ?? now
)
setRangeMessages(messages)
} catch {
setRangeMessages([])
} finally {
setRangeLoading(false)
}
}
void loadRange()
}, [open, reportRange, selected])
React.useEffect(() => {
if (!open || !selected) return
const getGroupSnapshot = window.api.getGroupSnapshot
if (typeof getGroupSnapshot !== 'function') {
setMemberCountAvailable(false)
return
}
let cancelled = false
void getGroupSnapshot(selected.md5)
.then((snapshot) => {
if (cancelled) return
setMemberCount(snapshot?.memberCount ?? snapshot?.members?.length ?? null)
})
.catch(() => {
if (!cancelled) setMemberCountAvailable(false)
})
return () => {
cancelled = true
}
}, [open, selected])
const filteredGroups = React.useMemo(() => {
const lower = keyword.trim().toLowerCase()
if (!lower) return groups
return groups.filter((contact) =>
`${contactDisplayName(contact)} ${contact.m_nsUsrName}`.toLowerCase().includes(lower)
)
}, [groups, keyword])
const submit = async (): Promise<void> => {
if (!selected || !name.trim()) return
await onSubmit(
{
name: name.trim(),
group: selected.md5,
target: selected.m_nsUsrName,
scheduleTime,
reportRange,
messageTypes: selectedMessageTypes,
templateId,
memberNameMode,
timeoutSeconds
},
task?.id
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === 'create' ? '新建定时日报' : '编辑定时日报'}</DialogTitle>
<DialogDescription>每天自动生成群聊日报,并发送到指定微信群。</DialogDescription>
</DialogHeader>
<div className="grid gap-5">
<label className="grid gap-2 text-sm font-medium">
任务名称
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:技术交流 · 每日日报"
/>
</label>
<div className="grid gap-2">
<span className="text-sm font-medium">选择群聊</span>
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索群聊"
/>
<div className="h-44 overflow-y-auto rounded-lg border border-border-subtle bg-muted/10 p-1">
{filteredGroups.length ? (
filteredGroups.map((contact) => {
const selectedRow = selected?.md5 === contact.md5
return (
<button
type="button"
key={contact.md5}
onClick={() => {
setSelectedId(contact.md5)
if (!name.trim()) setName(`${contactDisplayName(contact)} · 每日日报`)
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors ${selectedRow ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'}`}
>
<GroupAvatar contact={contact} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{contactDisplayName(contact)}
</span>
<span className="block text-xs text-muted-foreground">
微信群聊 ·{' '}
{selectedRow
? memberCountAvailable
? memberCount === null
? '成员信息加载中…'
: `${memberCount} 名成员`
: '成员信息暂不可用'
: '选择后读取成员数'}
</span>
</span>
<span
aria-hidden
className={`h-4 w-4 rounded-full border ${selectedRow ? 'border-primary bg-primary' : 'border-border'}`}
>
{selectedRow && (
<span className="mx-auto mt-1 block h-1.5 w-1.5 rounded-full bg-primary-foreground" />
)}
</span>
</button>
)
})
) : (
<p className="px-3 py-5 text-center text-sm text-muted-foreground">
没有匹配的群聊
</p>
)}
</div>
</div>
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium">总结范围</span>
<span className="text-xs text-muted-foreground">
{rangeLoading ? '正在计算' : `${rangeMessages.length} 条消息`}
</span>
</div>
<SegmentedControl
className="w-full"
aria-label="总结范围"
value={reportRange}
onValueChange={(value) => setReportRange(value as ScheduledReportRange)}
>
{SUMMARY_DATE_OPTIONS.map((option) => (
<SegmentedControlItem
key={option.value}
value={option.value}
className="min-h-9 flex-1"
>
{option.label}
</SegmentedControlItem>
))}
<SegmentedControlItem value="recent24h" className="min-h-9 flex-1">
最近24小时
</SegmentedControlItem>
<span
aria-disabled="true"
title="当前业务尚未支持自定义时间"
className="inline-flex min-h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center rounded-sm px-3 text-xs font-medium text-disabled-foreground opacity-100"
>
自定义 <small>即将支持</small>
</span>
</SegmentedControl>
</div>
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium">纳入的消息类型</span>
<span className="text-xs text-muted-foreground">至少选择一种</span>
</div>
<div className="grid gap-1 rounded-lg border border-border-subtle p-2">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label
key={option.value}
className="flex items-start gap-2 rounded-md px-2 py-2 hover:bg-accent"
>
<Checkbox
aria-label={option.label}
checked={selectedMessageTypes.includes(option.value)}
disabled={
selectedMessageTypes.length === 1 &&
selectedMessageTypes.includes(option.value)
}
onCheckedChange={() => {
setSelectedMessageTypes((current) =>
current.includes(option.value)
? current.filter((item) => item !== option.value)
: [...current, option.value]
)
}}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{option.label}</span>
<span className="block text-xs text-muted-foreground">
{option.description}
</span>
</span>
<span className="text-xs text-muted-foreground">
{messageTypeCounts(rangeMessages)[option.value]}
</span>
</label>
))}
</div>
</div>
<div className="grid gap-2">
<span className="text-sm font-medium">日报内容</span>
<p className="text-xs text-muted-foreground">
与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、活跃成员、活跃时间分布、关键词和内容密度。
</p>
</div>
<div className="rounded-lg border border-border-subtle bg-muted/20 px-3 py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<span className="font-medium">模型配置</span>
<p className="mt-1 text-xs text-muted-foreground">
使用当前日报默认的文字总结和图片理解模型。
</p>
</div>
{onOpenModelSettings && (
<Button variant="link" size="sm" onClick={onOpenModelSettings}>
更改模型
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
日报模板
<Select
value={templateId}
onValueChange={(value) => setTemplateId(value as SelectableReportTemplateId)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value={DEFAULT_REPORT_TEMPLATE.id}>
{DEFAULT_REPORT_TEMPLATE.name}
</SelectItem>
{REPORT_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.label} · {template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="grid gap-2 text-sm font-medium">
成员名称
<Select
value={memberNameMode}
onValueChange={(value) => setMemberNameMode(value as ScheduledReportMemberNameMode)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value="groupNickname">群昵称</SelectItem>
<SelectItem value="wechatNickname">微信昵称</SelectItem>
<SelectItem value="remark">通讯录备注</SelectItem>
</SelectContent>
</Select>
</label>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
执行时间
<Input
type="time"
value={scheduleTime}
onChange={(event) => setScheduleTime(event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm font-medium">
日报生成超时
<div className="flex items-center gap-2">
<Input
type="number"
min={30}
max={1800}
step={30}
value={timeoutSeconds}
onChange={(event) => setTimeoutSeconds(Number(event.target.value) || 300)}
/>
<span className="text-sm text-muted-foreground">秒</span>
</div>
</label>
</div>
<div className="rounded-lg border border-border-subtle bg-muted/20 px-3 py-3 text-sm">
<span className="text-muted-foreground">发送到</span>
<span className="ml-2 font-medium">
{selected ? contactDisplayName(selected) : '请选择微信群'}
</span>
</div>
<p className="text-xs leading-5 text-muted-foreground">
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,TraceMemo
本身不额外保存或转发内容。
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
取消
</Button>
<Button
onClick={() => void submit()}
disabled={busy || !selected || !name.trim() || !scheduleTime}
>
{busy ? '保存中…' : mode === 'create' ? '创建定时日报' : '保存修改'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function ScheduledReportsWorkspace({
contacts,
platformSupported = true,
onOpenWechatSettings,
onOpenModelSettings,
onNotice
}: ScheduledReportsWorkspaceProps): React.ReactElement {
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
const [capabilityError, setCapabilityError] = React.useState(false)
const [tasks, setTasks] = React.useState<ScheduledReportTask[]>([])
const [executions, setExecutions] = React.useState<ScheduledReportExecution[]>([])
const [loading, setLoading] = React.useState(true)
const [listError, setListError] = React.useState(false)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [dialogMode, setDialogMode] = React.useState<ReportDialogMode>('create')
const [editingTask, setEditingTask] = React.useState<ScheduledReportTask | null>(null)
const [saving, setSaving] = React.useState(false)
const [deletingTask, setDeletingTask] = React.useState<ScheduledReportTask | null>(null)
const [busyTaskId, setBusyTaskId] = React.useState<string | null>(null)
const load = React.useCallback(async (): Promise<void> => {
setLoading(true)
setListError(false)
if (!platformSupported) {
setCapability({
supported: false,
ready: false,
status: 'unsupported',
capabilities: { text: false, image: false, voice: false },
senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
message: '微信消息发送目前仅支持 macOS'
})
setTasks([])
setExecutions([])
setLoading(false)
return
}
const [taskResult, executionResult, capabilityResult] = await Promise.allSettled([
window.api.listScheduledReports(),
window.api.listScheduledReportExecutions(),
window.api.getPersonalWechatSendCapability()
])
if (taskResult.status === 'fulfilled') setTasks(taskResult.value)
else setListError(true)
if (executionResult.status === 'fulfilled') setExecutions(executionResult.value)
if (capabilityResult.status === 'fulfilled') {
setCapability(capabilityResult.value)
setCapabilityError(false)
} else {
setCapabilityError(true)
}
setLoading(false)
}, [platformSupported])
React.useEffect(() => {
void load()
}, [load])
const refreshTasks = React.useCallback(async (): Promise<void> => {
const [nextTasks, nextExecutions] = await Promise.all([
window.api.listScheduledReports(),
window.api.listScheduledReportExecutions()
])
setTasks(nextTasks)
setExecutions(nextExecutions)
}, [])
React.useEffect(() => {
const timer = window.setInterval(() => {
if (dialogOpen) return
void refreshTasks().catch(() => undefined)
void window.api
.getPersonalWechatSendCapability()
.then((nextCapability) => {
setCapability(nextCapability)
setCapabilityError(false)
})
.catch(() => setCapabilityError(true))
}, 15_000)
return () => window.clearInterval(timer)
}, [dialogOpen, refreshTasks])
const submit = async (input: ScheduledReportCreateInput, taskId?: string): Promise<void> => {
setSaving(true)
try {
const result = taskId
? await window.api.updateScheduledReport(taskId, input)
: await window.api.createScheduledReport(input)
if (!result.success) {
onNotice(readableError(result.error), 'destructive')
return
}
setDialogOpen(false)
await refreshTasks()
onNotice(taskId ? '定时日报已更新' : '定时日报已创建', 'success')
} catch (error) {
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
} finally {
setSaving(false)
}
}
const setEnabled = async (task: ScheduledReportTask, enabled: boolean): Promise<void> => {
setBusyTaskId(task.id)
try {
const result = await window.api.setScheduledReportEnabled(task.id, enabled)
if (!result.success) onNotice(readableError(result.error), 'destructive')
else await refreshTasks()
} catch (error) {
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
} finally {
setBusyTaskId(null)
}
}
const runNow = async (task: ScheduledReportTask): Promise<void> => {
setBusyTaskId(task.id)
try {
const result = await window.api.runScheduledReportNow(task.id)
await refreshTasks()
if (!result.success)
onNotice(readableError(result.error || result.data?.error), 'destructive')
else onNotice('日报已生成并发送', 'success')
} catch (error) {
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
} finally {
setBusyTaskId(null)
}
}
const confirmDelete = async (): Promise<void> => {
if (!deletingTask) return
setBusyTaskId(deletingTask.id)
try {
const result = await window.api.deleteScheduledReport(deletingTask.id)
if (!result.success) onNotice(readableError(result.error), 'destructive')
else {
setDeletingTask(null)
await refreshTasks()
onNotice('定时日报已删除', 'success')
}
} catch (error) {
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
} finally {
setBusyTaskId(null)
}
}
const openCreate = (): void => {
setDialogMode('create')
setEditingTask(null)
setDialogOpen(true)
}
const groupsById = React.useMemo(
() => new Map(contacts.map((contact) => [contact.md5, contact])),
[contacts]
)
const sortedExecutions = React.useMemo(
() =>
[...executions]
.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))
.slice(0, 8),
[executions]
)
const canCreate = capability?.status === 'ready'
return (
<div className="report-scheduled-page">
<div className="report-scheduled-header">
<div>
<h1>定时日报</h1>
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
</div>
<Button onClick={openCreate} disabled={!canCreate}>
<span aria-hidden>+</span> 新建定时日报
</Button>
</div>
<div className={`report-wechat-capability ${capabilityTone(capability)}`}>
<span className="font-medium">微信发送能力</span>
<span
aria-hidden
className={`ml-3 h-2 w-2 rounded-full ${capability?.status === 'ready' ? 'bg-success' : capability?.status === 'unsupported' ? 'bg-muted-foreground/50' : 'bg-warning'}`}
/>
<span className="ml-3">
{capabilityError ? '微信发送能力加载失败' : capabilityCopy(capability)}
</span>
{(capability?.status === 'needs_binding' ||
capability?.status === 'unconfigured' ||
capability?.status === 'needs_verification' ||
capability?.status === 'error' ||
capabilityError) && (
<Button variant="link" size="sm" className="ml-auto" onClick={onOpenWechatSettings}>
{capability?.status === 'needs_verification' ? '去检测' : '去配置'}
</Button>
)}
</div>
{listError && (
<div className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<span>加载定时日报失败,请重试。</span>
<Button variant="outline" size="sm" onClick={() => void load()}>
重试
</Button>
</div>
)}
{loading ? (
<div className="grid gap-3">
{[0, 1, 2].map((item) => (
<Skeleton key={item} className="h-24 w-full" />
))}
</div>
) : tasks.length ? (
<div className="overflow-x-auto rounded-xl border border-border-subtle bg-surface">
<div className="min-w-[760px]">
<div className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_190px] gap-3 border-b border-border-subtle px-5 py-3 text-xs font-medium text-muted-foreground">
<span>任务名称</span>
<span>执行时间</span>
<span>日报范围</span>
<span>发送目标</span>
<span>状态</span>
<span>下次执行</span>
<span className="text-right">操作</span>
</div>
{tasks.map((task) => {
const group = groupsById.get(task.group)
const label = group ? contactDisplayName(group) : task.target || task.group
const running = busyTaskId === task.id
return (
<div
key={task.id}
className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_190px] items-center gap-3 border-b border-border-subtle px-5 py-4 last:border-b-0"
>
<div className="flex min-w-0 items-center gap-3">
{group && <GroupAvatar contact={group} />}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{task.name}</p>
<p className="truncate text-xs text-muted-foreground">{label}</p>
</div>
</div>
<span className="text-sm">每天 {task.scheduleTime}</span>
<span className="text-sm text-muted-foreground">
{rangeLabel(task.reportRange)}
</span>
<span className="truncate text-sm text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 text-xs">
<Switch
aria-label={`${task.name} ${task.enabled ? '已启用' : '已暂停'}`}
checked={task.enabled}
disabled={running}
onCheckedChange={(value) => void setEnabled(task, value)}
/>
<span className={task.enabled ? 'text-success' : 'text-muted-foreground'}>
{task.enabled ? '已启用' : '已暂停'}
</span>
</div>
<span className="text-sm text-muted-foreground">
{task.enabled ? formatDateTime(task.nextRunAt) : '已暂停'}
</span>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => void runNow(task)}
disabled={running}
>
{running ? '生成中…' : '立即执行'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDialogMode('edit')
setEditingTask(task)
setDialogOpen(true)
}}
disabled={running}
>
编辑
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeletingTask(task)}
disabled={running}
>
删除
</Button>
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="report-scheduled-empty">
<div
className="grid h-12 w-12 place-items-center rounded-xl bg-primary/10 text-2xl text-primary"
aria-hidden
>
◷
</div>
<h2>还没有定时日报</h2>
<p>创建一个任务,让 TraceMemo 每天自动生成群聊日报并发送到微信。</p>
<Button onClick={openCreate} disabled={!canCreate}>
<span aria-hidden>+</span> 新建定时日报
</Button>
</div>
)}
<section className="report-scheduled-executions">
<div className="mb-3 flex items-end justify-between">
<div>
<h2>执行记录</h2>
<p>最近几次定时日报执行结果</p>
</div>
</div>
{sortedExecutions.length ? (
<div className="grid gap-2">
{sortedExecutions.map((execution) => {
const task = tasks.find((item) => item.id === execution.taskId)
const success = execution.status === 'success'
return (
<div
key={execution.id}
className="flex items-center gap-3 rounded-lg border border-border-subtle bg-surface px-4 py-3"
>
<span
className={`text-lg ${success ? 'text-success' : execution.status === 'running' ? 'text-warning' : 'text-destructive'}`}
aria-hidden
>
{success ? '✓' : execution.status === 'running' ? '…' : '×'}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{task?.name || '定时日报'}</p>
<p className="text-xs text-muted-foreground">{executionLabel(execution)}</p>
</div>
<time className="text-xs text-muted-foreground">
{formatDateTime(execution.finishedAt || execution.startedAt)}
</time>
</div>
)
})}
</div>
) : (
<p className="rounded-lg border border-dashed border-border-subtle px-4 py-6 text-center text-sm text-muted-foreground">
暂无执行记录
</p>
)}
</section>
<ScheduledReportDialog
open={dialogOpen}
mode={dialogMode}
task={editingTask}
contacts={contacts}
busy={saving}
onOpenModelSettings={onOpenModelSettings}
onOpenChange={setDialogOpen}
onSubmit={submit}
/>
<AlertDialog
open={Boolean(deletingTask)}
onOpenChange={(open) => !open && setDeletingTask(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除定时日报?</AlertDialogTitle>
<AlertDialogDescription>删除后不会再自动生成和发送该日报。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(busyTaskId)}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault()
void confirmDelete()
}}
disabled={Boolean(busyTaskId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -13,6 +13,7 @@ import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
import { TextToSpeechPage } from './pages/TextToSpeechPage'
import { PersonalWechatSendPage } from './pages/PersonalWechatSendPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -32,6 +33,7 @@ export function SettingsWorkspace({
onAIRuntimeChange,
onNotice,
onOpenSettings,
onOpenTextToSpeechSettings,
onAppearanceChange,
onSwitchAccount
}: {
@@ -50,6 +52,7 @@ export function SettingsWorkspace({
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
onOpenSettings: () => void
onOpenTextToSpeechSettings?: () => void
onAppearanceChange: (settings: {
theme: 'system' | 'light' | 'dark'
compactMode: boolean
@@ -92,6 +95,13 @@ export function SettingsWorkspace({
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
case 'voice-recognition':
return <VoiceRecognitionPage onNotice={onNotice} />
case 'wechat-send':
return (
<PersonalWechatSendPage
onNotice={onNotice}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
)
case 'text-to-speech':
return <TextToSpeechPage onNotice={onNotice} />
case 'recall-protection':
@@ -17,6 +17,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
{
label: '智能能力',
items: [
{ id: 'wechat-send', label: '微信发送' },
{ id: 'voice-recognition', label: '语音转文字' },
{ id: 'text-to-speech', label: '文字转语音' },
{ id: 'ai-model', label: 'AI 模型' }
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
| 'database-key'
| 'image-key'
| 'voice-recognition'
| 'wechat-send'
| 'text-to-speech'
| 'ai-model'
| 'recall-protection'
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import type {
PersonalWechatSendCapability,
PersonalWechatSenderStatus
} from '../../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../../shared/personal-wechat-runtime'
import { Button, Skeleton } from '../../../components/ui'
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> = {
unsupported: '暂不支持',
unconfigured: '尚未配置',
needs_binding: '需要绑定',
needs_verification: '需要检测',
ready: '已就绪',
error: '异常'
}
function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolean {
if (!status) return false
return (
status.state === 'online' ||
Boolean(
status.wechatPid &&
status.boundWechatPid === status.wechatPid &&
status.attachReady &&
status.baseAddressReady
)
)
}
export function PersonalWechatSendPage({
onNotice,
onOpenTextToSpeechSettings
}: {
onNotice: (message: string) => void
onOpenTextToSpeechSettings?: () => void
}): ReactElement {
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
null
)
const [loading, setLoading] = useState(true)
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [binding, setBinding] = useState(false)
const [detecting, setDetecting] = useState(false)
const [detectionAttempted, setDetectionAttempted] = useState(false)
const [error, setError] = useState('')
const refresh = useCallback(async (): Promise<void> => {
setError('')
try {
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
window.api.getPersonalWechatSendCapability(),
window.api.getPersonalWechatSenderStatus(),
window.api.getPersonalWechatRuntimeStatus()
])
setCapability(nextCapability)
setSenderStatus(nextSender)
setRuntimeStatus(nextRuntime)
setRuntimeProgress(nextRuntime.state === 'downloading' ? nextRuntime : null)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '微信发送能力读取失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
if (status.state === 'ready') void refresh()
})
return unsubscribe
}, [refresh])
const downloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
setRuntimeBusy(true)
setError('')
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
if (!result.success) setError(result.error || '微信发送组件准备失败')
else onNotice('微信发送组件已准备好')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '微信发送组件准备失败')
} finally {
setRuntimeBusy(false)
}
}
const bindWechat = async (): Promise<void> => {
if (binding) return
setBinding(true)
setError('')
setDetectionAttempted(false)
try {
const nextStatus = await window.api.rebindPersonalWechatSender()
setSenderStatus(nextStatus)
const nextCapability = await window.api.getPersonalWechatSendCapability()
setCapability(nextCapability)
if (nextStatus.state !== 'online' && nextStatus.message) setError(nextStatus.message)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '个人微信绑定失败')
} finally {
setBinding(false)
}
}
const detectCapability = async (): Promise<void> => {
if (detecting) return
setDetecting(true)
setDetectionAttempted(true)
try {
await refresh()
} finally {
setDetecting(false)
}
}
const status = capability?.status || 'error'
const ready = capability?.ready === true
return (
<div className="settings-page personal-wechat-send-page">
<header className="settings-page-header">
<div>
<h1>微信发送</h1>
<p>管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。</p>
</div>
<span
className={`settings-status-badge ${loading ? 'checking' : ready ? '' : status === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[status]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
{loading && !capability ? (
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">
{capability?.message || '微信发送能力暂不可用'}
</strong>
<p className="mt-2 text-sm text-muted-foreground">
{status === 'unsupported'
? '微信消息发送目前仅支持 macOS。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', capability?.capabilities.text],
['图片', capability?.capabilities.image],
['语音', capability?.capabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
/>
</>
)}
</div>
</div>
</div>
)
}
+149
View File
@@ -9,6 +9,155 @@
background: var(--wxex-bg-main);
}
.report-workspace-shell {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
flex-direction: column;
overflow: hidden;
background: var(--wxex-bg-main);
}
.report-workspace-tabs {
display: flex;
flex: 0 0 auto;
gap: 22px;
align-items: center;
padding: 14px 24px 0;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-workspace-tabs button {
position: relative;
padding: 0 0 12px;
border: 0;
background: transparent;
color: var(--wxex-text-muted);
font: 600 13px/20px var(--wxex-font);
cursor: pointer;
}
.report-workspace-tabs button:hover,
.report-workspace-tabs button.active {
color: var(--wxex-brand);
}
.report-workspace-tabs button.active::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: var(--wxex-brand);
content: '';
}
.report-workspace-tabs button.unsupported {
color: var(--wxex-text-muted);
cursor: not-allowed;
}
.report-workspace-tabs button.unsupported small {
margin-left: 5px;
font-size: 10px;
font-weight: 500;
}
.report-workspace-body {
min-height: 0;
flex: 1 1 auto;
}
.report-workspace-body > .report-page,
.report-workspace-body > .report-center-page {
height: 100%;
}
.report-scheduled-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 18px;
overflow-y: auto;
padding: 28px clamp(20px, 4vw, 56px) 40px;
color: var(--wxex-text-primary);
}
.report-scheduled-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.report-scheduled-header h1 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 24px/32px var(--wxex-font);
}
.report-scheduled-header p,
.report-scheduled-executions p {
margin: 5px 0 0;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.report-wechat-capability {
display: flex;
min-height: 42px;
align-items: center;
border: 1px solid;
border-radius: 10px;
padding: 8px 12px;
font: 13px/20px var(--wxex-font);
}
.report-scheduled-empty {
display: grid;
min-height: 260px;
place-items: center;
align-content: center;
gap: 10px;
border: 1px dashed var(--wxex-border);
border-radius: 12px;
background: var(--wxex-bg-elevated);
text-align: center;
}
.report-scheduled-empty h2,
.report-scheduled-executions h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 16px/24px var(--wxex-font);
}
.report-scheduled-empty p {
max-width: 420px;
margin: 0 16px 6px;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.report-scheduled-executions {
margin-top: 6px;
}
@media (max-width: 760px) {
.report-scheduled-header {
flex-direction: column;
}
.report-scheduled-header > button {
width: 100%;
}
}
.report-center-page {
display: grid;
grid-template-columns: 292px minmax(420px, 1fr) 304px;
+30 -2
View File
@@ -1,4 +1,15 @@
export type ScheduledReportRange = 'yesterday' | 'recent24h'
import type { SelectableReportTemplateId } from './report-templates'
export type ScheduledReportRange = 'today' | 'yesterday' | '7days' | 'recent24h'
export type ScheduledReportMessageType =
| 'text'
| 'image'
| 'sticker'
| 'video'
| 'voice'
| 'share'
| 'system'
export type ScheduledReportMemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
export type ScheduledReportExecutionStatus = 'running' | 'success' | 'failed'
export interface ScheduledReportTask {
@@ -8,6 +19,10 @@ export interface ScheduledReportTask {
group: string
scheduleTime: string
reportRange: ScheduledReportRange
messageTypes?: ScheduledReportMessageType[]
templateId?: SelectableReportTemplateId
memberNameMode?: ScheduledReportMemberNameMode
timeoutSeconds?: number
/** Current implementation targets one specified WeChat group. */
target: string
enabled: boolean
@@ -35,6 +50,10 @@ export interface ScheduledReportCreateInput {
group: string
scheduleTime: string
reportRange?: ScheduledReportRange
messageTypes?: ScheduledReportMessageType[]
templateId?: SelectableReportTemplateId
memberNameMode?: ScheduledReportMemberNameMode
timeoutSeconds?: number
target?: string
enabled?: boolean
}
@@ -42,7 +61,16 @@ export interface ScheduledReportCreateInput {
export type ScheduledReportUpdateInput = Partial<
Pick<
ScheduledReportCreateInput,
'name' | 'group' | 'scheduleTime' | 'reportRange' | 'target' | 'enabled'
| 'name'
| 'group'
| 'scheduleTime'
| 'reportRange'
| 'messageTypes'
| 'templateId'
| 'memberNameMode'
| 'timeoutSeconds'
| 'target'
| 'enabled'
>
>
@@ -0,0 +1,111 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
import type { Contact } from '../../src/shared/types'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
const group: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '技术交流',
type: 'group'
}
const capability = (
status: PersonalWechatSendCapability['status']
): PersonalWechatSendCapability => ({
supported: status !== 'unsupported',
ready: status === 'ready',
status,
capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' },
senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
message: ''
})
const task = {
id: 'task-1',
name: '技术交流 · 每日日报',
group: group.md5,
target: group.m_nsUsrName,
scheduleTime: '09:00',
reportRange: 'yesterday' as const,
enabled: true,
createdAt: '2026-08-26T00:00:00.000Z',
updatedAt: '2026-08-26T00:00:00.000Z',
nextRunAt: '2026-08-28T01:00:00.000Z'
}
describe('ScheduledReportsWorkspace', () => {
const onNotice = vi.fn()
const onOpenWechatSettings = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: vi.fn(async () => capability('ready')),
listScheduledReports: vi.fn(async () => [task]),
listScheduledReportExecutions: vi.fn(async () => []),
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
updateScheduledReport: vi.fn(async () => ({ success: true, data: task })),
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } }))
}
})
})
it('loads capability and renders a scheduled report task', async () => {
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
/>
)
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('✓ 微信发送能力已就绪')).toBeVisible())
expect(screen.getByText(task.name)).toBeVisible()
expect(screen.getByText('每天 09:00')).toBeVisible()
})
it('disables creation and offers settings when WeChat is unsupported', async () => {
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
capability('unsupported')
)
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
/>
)
await waitFor(() => expect(screen.getByText('微信消息发送目前仅支持 macOS')).toBeVisible())
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeDisabled()
})
it('opens the shared dialog and creates a task', async () => {
vi.mocked(window.api.listScheduledReports).mockResolvedValue([])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
/>
)
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0])
expect(screen.getByRole('dialog')).toBeVisible()
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText('例如:技术交流 · 每日日报'), {
target: { value: '测试日报' }
})
fireEvent.click(within(dialog).getByRole('button', { name: '创建定时日报' }))
await waitFor(() =>
expect(window.api.createScheduledReport).toHaveBeenCalledWith(
expect.objectContaining({ name: '测试日报', group: group.md5, target: group.m_nsUsrName })
)
)
})
})
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test'
import { launchTestApp } from './support/electron'
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
test.skip(process.platform !== 'darwin', 'The scheduled report send capability is macOS-only')
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '定时日报', exact: true })).toBeVisible()
await expect(fixture.page.getByText('✓ 微信发送能力已就绪')).toBeVisible()
const createButton = fixture.page.getByRole('button', { name: /新建定时日报/ }).first()
await expect(createButton).toBeEnabled()
await createButton.click()
const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('button', { name: /产品测试群 .*微信群聊/ })).toBeVisible()
await expect(dialog.getByPlaceholder('搜索群聊')).toBeVisible()
await expect(dialog.getByRole('button', { name: '创建定时日报' })).toBeVisible()
const selects = dialog.getByRole('combobox')
await expect(selects).toHaveCount(2)
await selects.nth(0).click()
const templateOption = fixture.page.getByRole('option', { name: /Mobile 01.*微信信息流/ })
await expect(templateOption).toBeVisible()
await templateOption.click()
await expect(selects.nth(0)).toContainText('微信信息流')
await selects.nth(1).click()
const memberOption = fixture.page.getByRole('option', { name: '微信昵称' })
await expect(memberOption).toBeVisible()
await memberOption.click()
await expect(selects.nth(1)).toContainText('微信昵称')
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600)
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('E2E 定时日报')
await dialog.getByRole('radio', { name: '今天' }).click()
await dialog.getByRole('button', { name: '创建定时日报' }).click()
await expect(dialog).toHaveCount(0)
await expect(fixture.page.getByText('E2E 定时日报', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('今天', { exact: true })).toBeVisible()
await fixture.page.getByRole('button', { name: '设置' }).click()
await fixture.page.getByRole('button', { name: '微信发送', exact: true }).click()
await expect(fixture.page.getByRole('heading', { name: '微信发送' })).toBeVisible()
await expect(fixture.page.getByRole('heading', { name: '发送能力' })).toBeVisible()
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
+112
View File
@@ -234,6 +234,18 @@ const handle = (channel, fn) => {
ipcMain.handle(channel, async (event, ...args) => fn(...args))
}
const scheduledReportTasks = []
const scheduledReportExecutions = []
const scheduledReportNextRun = (scheduleTime) => {
const [hours, minutes] = String(scheduleTime || '09:00')
.split(':')
.map(Number)
const next = new Date(fixtureNowMs)
next.setHours(Number.isFinite(hours) ? hours : 9, Number.isFinite(minutes) ? minutes : 0, 0, 0)
if (next.getTime() <= fixtureNowMs) next.setDate(next.getDate() + 1)
return next.toISOString()
}
const startupCache = () => ({
self: fixture.self,
contacts,
@@ -299,6 +311,106 @@ handle('wechat-personal:getStatus', () => ({
canSendVoice: true,
message: '个人微信已绑定'
}))
handle('wechat-personal:getSendCapability', () => ({
supported: process.platform === 'darwin',
ready: process.platform === 'darwin',
status: process.platform === 'darwin' ? 'ready' : 'unsupported',
capabilities: {
text: process.platform === 'darwin',
image: process.platform === 'darwin',
voice: process.platform === 'darwin'
},
senderStatus: {
state: process.platform === 'darwin' ? 'online' : 'unsupported_platform',
platform: process.platform,
arch: process.arch,
sipDisabled: true,
wechatRunning: true,
boundWechatPid: 4668,
endpoint: '127.0.0.1:58080',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: process.platform === 'darwin',
canSendText: process.platform === 'darwin',
canSendImage: process.platform === 'darwin',
canSendVoice: process.platform === 'darwin',
message: '个人微信已绑定'
},
message:
process.platform === 'darwin' ? '个人微信已准备好发送日报' : '微信消息发送目前仅支持 macOS'
}))
handle('scheduled-report:list', () => [...scheduledReportTasks])
handle('scheduled-report:listExecutions', (taskId) =>
taskId
? scheduledReportExecutions.filter((execution) => execution.taskId === taskId)
: [...scheduledReportExecutions]
)
handle('scheduled-report:create', (request) => {
const now = new Date(fixtureNowMs).toISOString()
const task = {
id: `fixture-scheduled-report-${scheduledReportTasks.length + 1}`,
name: request.name,
group: request.group,
scheduleTime: request.scheduleTime,
reportRange: request.reportRange || 'yesterday',
messageTypes: request.messageTypes,
templateId: request.templateId,
memberNameMode: request.memberNameMode,
timeoutSeconds: request.timeoutSeconds,
target: request.target || request.group,
enabled: request.enabled !== false,
createdAt: now,
updatedAt: now,
nextRunAt: scheduledReportNextRun(request.scheduleTime)
}
scheduledReportTasks.push(task)
return { success: true, data: task }
})
handle('scheduled-report:update', (taskId, request) => {
const index = scheduledReportTasks.findIndex((task) => task.id === taskId)
if (index < 0) return { success: false, error: '任务不存在' }
const current = scheduledReportTasks[index]
const next = { ...current, ...request, updatedAt: new Date(fixtureNowMs).toISOString() }
if (request.scheduleTime) next.nextRunAt = scheduledReportNextRun(request.scheduleTime)
scheduledReportTasks[index] = next
return { success: true, data: next }
})
handle('scheduled-report:delete', (taskId) => {
const index = scheduledReportTasks.findIndex((task) => task.id === taskId)
if (index < 0) return { success: false, error: '任务不存在' }
scheduledReportTasks.splice(index, 1)
return { success: true, data: { deletedId: taskId } }
})
handle('scheduled-report:setEnabled', (taskId, enabled) => {
const task = scheduledReportTasks.find((item) => item.id === taskId)
if (!task) return { success: false, error: '任务不存在' }
task.enabled = Boolean(enabled)
task.updatedAt = new Date(fixtureNowMs).toISOString()
task.nextRunAt = task.enabled ? scheduledReportNextRun(task.scheduleTime) : task.nextRunAt
return { success: true, data: task }
})
handle('scheduled-report:runNow', (taskId) => {
const task = scheduledReportTasks.find((item) => item.id === taskId)
if (!task) return { success: false, error: '任务不存在' }
const execution = {
id: `fixture-execution-${scheduledReportExecutions.length + 1}`,
taskId,
startedAt: new Date(fixtureNowMs).toISOString(),
finishedAt: new Date(fixtureNowMs).toISOString(),
status: 'success',
message: '日报生成成功,微信发送成功'
}
scheduledReportExecutions.push(execution)
task.lastRunAt = execution.finishedAt
return { success: true, data: execution }
})
handle('wechat-share:getConfig', () => ({
success: true,
configured: true,