diff --git a/src/main/services/agent-group-report-service.ts b/src/main/services/agent-group-report-service.ts index dda423b..5416d02 100644 --- a/src/main/services/agent-group-report-service.ts +++ b/src/main/services/agent-group-report-service.ts @@ -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 = { + 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 => 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 || '总结图片生成失败' } } diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts index 1490943..1806326 100644 --- a/src/main/services/scheduled-report-service.ts +++ b/src/main/services/scheduled-report-service.ts @@ -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(['yesterday', 'recent24h']) +const rangeValues = new Set(['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> 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 + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bda0527..0af518e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') + const [reportSection, setReportSection] = useState<'today' | 'scheduled'>('today') const [generatedReports, setGeneratedReports] = useState([]) const [selectedReportId, setSelectedReportId] = useState(null) const [latestGeneratedReportId, setLatestGeneratedReportId] = useState(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 { ) - const renderReportWorkspace = (): React.ReactElement => - reportWorkspaceView === 'result' ? ( -
- - 0} - onBackToConfigure={openReportConfigure} - onRegenerate={handleRegenerateReport} - onCopyImage={handleCopyReportImage} - onReveal={handleRevealReport} - onSwitchTemplate={handleSwitchReportTemplate} - sendTarget={selectedReportContact} - /> - + const renderReportWorkspace = (): React.ReactElement => ( +
+
+ +
- ) : ( -
- - { - 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} - /> - void reportGeneration.retry(model)} - onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()} - onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures} - /> +
+ {reportSection === 'scheduled' ? ( + + toast({ description: message, variant, duration: 3200 }) + } + /> + ) : reportWorkspaceView === 'result' ? ( +
+ + 0} + onBackToConfigure={openReportConfigure} + onRegenerate={handleRegenerateReport} + onCopyImage={handleCopyReportImage} + onReveal={handleRevealReport} + onSwitchTemplate={handleSwitchReportTemplate} + sendTarget={selectedReportContact} + /> + +
+ ) : ( +
+ + { + 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} + /> + void reportGeneration.retry(model)} + onContinueAfterImageFailures={() => + void reportGeneration.continueAfterImageFailures() + } + onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures} + /> +
+ )}
- ) +
+ ) const renderCurrentWorkspace = (): React.ReactElement => { switch (activePage) { @@ -1911,6 +1963,7 @@ function App(): React.ReactElement { }} onNotice={setReportNotice} onOpenSettings={openSettings} + onOpenTextToSpeechSettings={openTextToSpeechSettings} onAppearanceChange={handleAppearanceChange} onSwitchAccount={handleSwitchAccount} /> diff --git a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx new file mode 100644 index 0000000..b8d8e40 --- /dev/null +++ b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx @@ -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 => { + 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 ( + + {contact.avatar ? ( + {name} + ) : ( + name.slice(0, 1) + )} + + ) +} + +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 +}): 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('yesterday') + const [selectedMessageTypes, setSelectedMessageTypes] = + React.useState(allMessageTypes) + const [templateId, setTemplateId] = React.useState('v1') + const [memberNameMode, setMemberNameMode] = + React.useState('groupNickname') + const [timeoutSeconds, setTimeoutSeconds] = React.useState(300) + const [rangeMessages, setRangeMessages] = React.useState>([]) + const [rangeLoading, setRangeLoading] = React.useState(false) + const [memberCount, setMemberCount] = React.useState(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 => { + 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 => { + 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 ( + + + + {mode === 'create' ? '新建定时日报' : '编辑定时日报'} + 每天自动生成群聊日报,并发送到指定微信群。 + +
+ +
+ 选择群聊 + setKeyword(event.target.value)} + placeholder="搜索群聊" + /> +
+ {filteredGroups.length ? ( + filteredGroups.map((contact) => { + const selectedRow = selected?.md5 === contact.md5 + return ( + + ) + }) + ) : ( +

+ 没有匹配的群聊 +

+ )} +
+
+
+
+ 总结范围 + + {rangeLoading ? '正在计算' : `${rangeMessages.length} 条消息`} + +
+ setReportRange(value as ScheduledReportRange)} + > + {SUMMARY_DATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + 最近24小时 + + + 自定义 即将支持 + + +
+
+
+ 纳入的消息类型 + 至少选择一种 +
+
+ {SUMMARY_TYPE_OPTIONS.map((option) => ( + + ))} +
+
+
+ 日报内容 +

+ 与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、活跃成员、活跃时间分布、关键词和内容密度。 +

+
+
+
+
+ 模型配置 +

+ 使用当前日报默认的文字总结和图片理解模型。 +

+
+ {onOpenModelSettings && ( + + )} +
+
+
+ + +
+
+ + +
+
+ 发送到 + + {selected ? contactDisplayName(selected) : '请选择微信群'} + +
+

+ 微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,TraceMemo + 本身不额外保存或转发内容。 +

+
+ + + + +
+
+ ) +} + +export function ScheduledReportsWorkspace({ + contacts, + platformSupported = true, + onOpenWechatSettings, + onOpenModelSettings, + onNotice +}: ScheduledReportsWorkspaceProps): React.ReactElement { + const [capability, setCapability] = React.useState(null) + const [capabilityError, setCapabilityError] = React.useState(false) + const [tasks, setTasks] = React.useState([]) + const [executions, setExecutions] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [listError, setListError] = React.useState(false) + const [dialogOpen, setDialogOpen] = React.useState(false) + const [dialogMode, setDialogMode] = React.useState('create') + const [editingTask, setEditingTask] = React.useState(null) + const [saving, setSaving] = React.useState(false) + const [deletingTask, setDeletingTask] = React.useState(null) + const [busyTaskId, setBusyTaskId] = React.useState(null) + + const load = React.useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 ( +
+
+
+

定时日报

+

每天自动生成群聊日报,并发送到指定微信群。

+
+ +
+
+ 微信发送能力 + + + {capabilityError ? '微信发送能力加载失败' : capabilityCopy(capability)} + + {(capability?.status === 'needs_binding' || + capability?.status === 'unconfigured' || + capability?.status === 'needs_verification' || + capability?.status === 'error' || + capabilityError) && ( + + )} +
+ {listError && ( +
+ 加载定时日报失败,请重试。 + +
+ )} + {loading ? ( +
+ {[0, 1, 2].map((item) => ( + + ))} +
+ ) : tasks.length ? ( +
+
+
+ 任务名称 + 执行时间 + 日报范围 + 发送目标 + 状态 + 下次执行 + 操作 +
+ {tasks.map((task) => { + const group = groupsById.get(task.group) + const label = group ? contactDisplayName(group) : task.target || task.group + const running = busyTaskId === task.id + return ( +
+
+ {group && } +
+

{task.name}

+

{label}

+
+
+ 每天 {task.scheduleTime} + + {rangeLabel(task.reportRange)} + + {label} +
+ void setEnabled(task, value)} + /> + + {task.enabled ? '已启用' : '已暂停'} + +
+ + {task.enabled ? formatDateTime(task.nextRunAt) : '已暂停'} + +
+ + + +
+
+ ) + })} +
+
+ ) : ( +
+
+ ◷ +
+

还没有定时日报

+

创建一个任务,让 TraceMemo 每天自动生成群聊日报并发送到微信。

+ +
+ )} +
+
+
+

执行记录

+

最近几次定时日报执行结果

+
+
+ {sortedExecutions.length ? ( +
+ {sortedExecutions.map((execution) => { + const task = tasks.find((item) => item.id === execution.taskId) + const success = execution.status === 'success' + return ( +
+ + {success ? '✓' : execution.status === 'running' ? '…' : '×'} + +
+

{task?.name || '定时日报'}

+

{executionLabel(execution)}

+
+ +
+ ) + })} +
+ ) : ( +

+ 暂无执行记录 +

+ )} +
+ + !open && setDeletingTask(null)} + > + + + 删除定时日报? + 删除后不会再自动生成和发送该日报。 + + + 取消 + { + event.preventDefault() + void confirmDelete() + }} + disabled={Boolean(busyTaskId)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 删除 + + + + +
+ ) +} diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index 726cecd..df614e1 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -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 case 'voice-recognition': return + case 'wechat-send': + return ( + + ) case 'text-to-speech': return case 'recall-protection': diff --git a/src/renderer/src/features/settings/model/settingsNavigation.ts b/src/renderer/src/features/settings/model/settingsNavigation.ts index 387a9db..c51c711 100644 --- a/src/renderer/src/features/settings/model/settingsNavigation.ts +++ b/src/renderer/src/features/settings/model/settingsNavigation.ts @@ -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 模型' } diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts index 56273fc..45f579b 100644 --- a/src/renderer/src/features/settings/model/types.ts +++ b/src/renderer/src/features/settings/model/types.ts @@ -3,6 +3,7 @@ export type SettingsCategoryId = | 'database-key' | 'image-key' | 'voice-recognition' + | 'wechat-send' | 'text-to-speech' | 'ai-model' | 'recall-protection' diff --git a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx new file mode 100644 index 0000000..3745fdf --- /dev/null +++ b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx @@ -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 = { + 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(null) + const [senderStatus, setSenderStatus] = useState(null) + const [runtimeStatus, setRuntimeStatus] = useState(null) + const [runtimeProgress, setRuntimeProgress] = useState( + 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 => { + 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 => { + 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 => { + 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 => { + if (detecting) return + setDetecting(true) + setDetectionAttempted(true) + try { + await refresh() + } finally { + setDetecting(false) + } + } + + const status = capability?.status || 'error' + const ready = capability?.ready === true + + return ( +
+
+
+

微信发送

+

管理个人微信消息发送能力,日报和档案发送都会使用这里的状态。

+
+ + {loading ? '检测中' : capabilityLabel[status]} + +
+
+
+ {loading && !capability ? ( + + ) : ( + <> +

发送能力

+
+
+
+ 个人微信 + + {capability?.message || '微信发送能力暂不可用'} + +

+ {status === 'unsupported' + ? '微信消息发送目前仅支持 macOS。' + : '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'} +

+
+ +
+
+ {( + [ + ['文字', capability?.capabilities.text], + ['图片', capability?.capabilities.image], + ['语音', capability?.capabilities.voice] + ] as const + ).map(([label, available]) => ( +
+ {label} + + {available ? '可发送' : '未就绪'} + +
+ ))} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ +

配置与检测

+ void downloadRuntime()} + onBind={() => void bindWechat()} + onDetect={() => void detectCapability()} + onStartSending={() => + onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。') + } + onOpenTextToSpeechSettings={onOpenTextToSpeechSettings} + /> + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss index aaa7bf3..d6acc75 100644 --- a/src/renderer/src/styles/reports-core.scss +++ b/src/renderer/src/styles/reports-core.scss @@ -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; diff --git a/src/shared/scheduled-report.ts b/src/shared/scheduled-report.ts index bd673d5..9481947 100644 --- a/src/shared/scheduled-report.ts +++ b/src/shared/scheduled-report.ts @@ -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' > > diff --git a/tests/component/scheduled-reports-workspace.test.tsx b/tests/component/scheduled-reports-workspace.test.tsx new file mode 100644 index 0000000..cd991d5 --- /dev/null +++ b/tests/component/scheduled-reports-workspace.test.tsx @@ -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( + + ) + 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( + + ) + 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( + + ) + 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 }) + ) + ) + }) +}) diff --git a/tests/e2e/scheduled-reports.spec.ts b/tests/e2e/scheduled-reports.spec.ts new file mode 100644 index 0000000..76a7bf7 --- /dev/null +++ b/tests/e2e/scheduled-reports.spec.ts @@ -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() + } +}) diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index cdab8a9..b36ff74 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -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,