mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 完善定时日报
This commit is contained in:
@@ -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 || '总结图片生成失败' }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user