mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 06:17:07 +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 { exportGroupReport } from '../group-report-service'
|
||||||
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
||||||
import { AIProviderService } from './ai-provider-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 {
|
import {
|
||||||
buildGroupReportInput,
|
buildGroupReportInput,
|
||||||
getSummaryDateRange,
|
getSummaryDateRange,
|
||||||
@@ -17,6 +23,10 @@ const aiProvider = new AIProviderService()
|
|||||||
export interface AgentGroupReportRequest {
|
export interface AgentGroupReportRequest {
|
||||||
group: string
|
group: string
|
||||||
range?: SummaryDateRange | 'recent24h'
|
range?: SummaryDateRange | 'recent24h'
|
||||||
|
messageTypes?: ScheduledReportMessageType[]
|
||||||
|
templateId?: SelectableReportTemplateId
|
||||||
|
memberNameMode?: ScheduledReportMemberNameMode
|
||||||
|
timeoutSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentGroupReportResult {
|
export interface AgentGroupReportResult {
|
||||||
@@ -49,12 +59,33 @@ export async function generateAgentGroupReport(
|
|||||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
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)
|
const snapshot = getGroupSnapshot(contact.md5)
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
const members = new Map(
|
const members = new Map(
|
||||||
snapshot.members.map((member) => [
|
snapshot.members.map((member) => [
|
||||||
member.wxid,
|
member.wxid,
|
||||||
{ name: member.nickname, avatar: member.avatar }
|
{
|
||||||
|
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
|
||||||
|
avatar: member.avatar
|
||||||
|
}
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
messages = messages.map((message) => {
|
messages = messages.map((message) => {
|
||||||
@@ -67,10 +98,13 @@ export async function generateAgentGroupReport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||||
const ai = await aiProvider.chat([
|
const ai = await aiProvider.chat(
|
||||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
[
|
||||||
{ role: 'user', content: input.prompt }
|
{ 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 总结失败' }
|
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||||
parseGroupDailyReport(
|
parseGroupDailyReport(
|
||||||
@@ -85,10 +119,13 @@ export async function generateAgentGroupReport(
|
|||||||
try {
|
try {
|
||||||
report = parseReport(ai.data)
|
report = parseReport(ai.data)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
const repaired = await aiProvider.chat([
|
const repaired = await aiProvider.chat(
|
||||||
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
[
|
||||||
{ role: 'user', content: ai.data }
|
{ 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) {
|
if (!repaired.success || !repaired.data) {
|
||||||
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||||
return {
|
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) {
|
if (!exported.success || !exported.pngPath) {
|
||||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportMessageType,
|
||||||
|
ScheduledReportMemberNameMode,
|
||||||
ScheduledReportRange,
|
ScheduledReportRange,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult,
|
||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
@@ -41,7 +43,7 @@ const defaultDependencies = (): ScheduledReportDependencies => ({
|
|||||||
isDatabaseReady: () => isChatReady()
|
isDatabaseReady: () => isChatReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
const rangeValues = new Set<ScheduledReportRange>(['yesterday', 'recent24h'])
|
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
||||||
|
|
||||||
export function validateScheduleTime(value: string): boolean {
|
export function validateScheduleTime(value: string): boolean {
|
||||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
||||||
@@ -112,6 +114,10 @@ export class ScheduledReportService {
|
|||||||
group: values.group,
|
group: values.group,
|
||||||
scheduleTime: values.scheduleTime,
|
scheduleTime: values.scheduleTime,
|
||||||
reportRange: values.reportRange,
|
reportRange: values.reportRange,
|
||||||
|
messageTypes: values.messageTypes,
|
||||||
|
templateId: values.templateId,
|
||||||
|
memberNameMode: values.memberNameMode,
|
||||||
|
timeoutSeconds: values.timeoutSeconds,
|
||||||
target: values.target,
|
target: values.target,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
@@ -286,7 +292,14 @@ export class ScheduledReportService {
|
|||||||
}
|
}
|
||||||
let generated: Awaited<ReturnType<typeof this.deps.generateReport>>
|
let generated: Awaited<ReturnType<typeof this.deps.generateReport>>
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
return finish(
|
return finish(
|
||||||
'failed',
|
'failed',
|
||||||
@@ -343,6 +356,10 @@ export class ScheduledReportService {
|
|||||||
group: string
|
group: string
|
||||||
scheduleTime: string
|
scheduleTime: string
|
||||||
reportRange: ScheduledReportRange
|
reportRange: ScheduledReportRange
|
||||||
|
messageTypes: ScheduledReportMessageType[]
|
||||||
|
templateId: ScheduledReportTask['templateId']
|
||||||
|
memberNameMode: ScheduledReportMemberNameMode
|
||||||
|
timeoutSeconds: number
|
||||||
target: string
|
target: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}> {
|
}> {
|
||||||
@@ -350,6 +367,15 @@ export class ScheduledReportService {
|
|||||||
const group = String(input.group || '').trim()
|
const group = String(input.group || '').trim()
|
||||||
const scheduleTime = String(input.scheduleTime || '').trim()
|
const scheduleTime = String(input.scheduleTime || '').trim()
|
||||||
const reportRange = input.reportRange || 'yesterday'
|
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()
|
const target = String(input.target || group).trim()
|
||||||
if (!name) return { success: false, error: '日报名称不能为空' }
|
if (!name) return { success: false, error: '日报名称不能为空' }
|
||||||
if (!group) 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: '日报范围不受支持' }
|
if (!rangeValues.has(reportRange)) return { success: false, error: '日报范围不受支持' }
|
||||||
return {
|
return {
|
||||||
success: true,
|
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 { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
||||||
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
||||||
import { ReportViewer } from './components/reports/ReportViewer'
|
import { ReportViewer } from './components/reports/ReportViewer'
|
||||||
|
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
|
||||||
import { contactDisplayName } from './components/reports/types'
|
import { contactDisplayName } from './components/reports/types'
|
||||||
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
|
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
|
||||||
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
||||||
@@ -248,6 +249,7 @@ function App(): React.ReactElement {
|
|||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||||
|
const [reportSection, setReportSection] = useState<'today' | 'scheduled'>('today')
|
||||||
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||||
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
||||||
@@ -1461,6 +1463,7 @@ function App(): React.ReactElement {
|
|||||||
if (page === 'report') {
|
if (page === 'report') {
|
||||||
setReportWorkspaceView('result')
|
setReportWorkspaceView('result')
|
||||||
setSelectedReportId(null)
|
setSelectedReportId(null)
|
||||||
|
setReportSection('today')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,6 +1487,11 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openWechatSendSettings = (): void => {
|
||||||
|
setSettingsCategory('wechat-send')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const dismissFirstUseWelcome = (): void => {
|
const dismissFirstUseWelcome = (): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||||
@@ -1751,102 +1759,146 @@ function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderReportWorkspace = (): React.ReactElement =>
|
const renderReportWorkspace = (): React.ReactElement => (
|
||||||
reportWorkspaceView === 'result' ? (
|
<div className="report-workspace-shell">
|
||||||
<div className="report-center-page">
|
<div className="report-workspace-tabs" role="tablist" aria-label="日报类型">
|
||||||
<ReportHistorySidebar
|
<button
|
||||||
reports={generatedReports}
|
type="button"
|
||||||
selectedReportId={selectedReportId}
|
role="tab"
|
||||||
selfInfo={selfInfo}
|
aria-selected={reportSection === 'today'}
|
||||||
dbReady={isDatabaseConnected}
|
className={reportSection === 'today' ? 'active' : ''}
|
||||||
dbConnecting={isDatabaseConnecting}
|
onClick={() => setReportSection('today')}
|
||||||
onSelectReport={openReport}
|
>
|
||||||
onCreateReport={openReportConfigure}
|
今日日报
|
||||||
onDeleteReport={handleDeleteReport}
|
</button>
|
||||||
onOpenSettings={openSettings}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<ReportViewer
|
role="tab"
|
||||||
report={selectedReport}
|
aria-selected={reportSection === 'scheduled'}
|
||||||
hasReports={generatedReports.length > 0}
|
aria-disabled={runtimePlatform !== 'darwin'}
|
||||||
onBackToConfigure={openReportConfigure}
|
className={`${reportSection === 'scheduled' ? 'active' : ''} ${runtimePlatform !== 'darwin' ? 'unsupported' : ''}`}
|
||||||
onRegenerate={handleRegenerateReport}
|
onClick={() => {
|
||||||
onCopyImage={handleCopyReportImage}
|
if (runtimePlatform !== 'darwin') {
|
||||||
onReveal={handleRevealReport}
|
toast({ description: '定时日报目前仅支持 macOS。', duration: 3200 })
|
||||||
onSwitchTemplate={handleSwitchReportTemplate}
|
return
|
||||||
sendTarget={selectedReportContact}
|
}
|
||||||
/>
|
setReportSection('scheduled')
|
||||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
}}
|
||||||
|
>
|
||||||
|
定时日报{runtimePlatform !== 'darwin' && <small>仅 macOS</small>}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="report-workspace-body">
|
||||||
<div className="report-page">
|
{reportSection === 'scheduled' ? (
|
||||||
<ReportSourceSidebar
|
<ScheduledReportsWorkspace
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
selectedContact={reportSourceContact}
|
platformSupported={runtimePlatform === 'darwin'}
|
||||||
selfInfo={selfInfo}
|
onOpenWechatSettings={openWechatSendSettings}
|
||||||
dbReady={isDatabaseConnected}
|
onOpenModelSettings={openModelSettings}
|
||||||
dbConnecting={isDatabaseConnecting}
|
onNotice={(message, variant) =>
|
||||||
onSelectContact={handleSelectReportSource}
|
toast({ description: message, variant, duration: 3200 })
|
||||||
onOpenSettings={openSettings}
|
}
|
||||||
/>
|
/>
|
||||||
<AiReportWorkspace
|
) : reportWorkspaceView === 'result' ? (
|
||||||
sourceContact={reportSourceContact}
|
<div className="report-center-page">
|
||||||
summaryDateRange={summaryDateRange}
|
<ReportHistorySidebar
|
||||||
summaryMessageTypes={summaryMessageTypes}
|
reports={generatedReports}
|
||||||
modelConfig={reportTextModelConfig}
|
selectedReportId={selectedReportId}
|
||||||
visionModelConfig={aiVisionModelConfig}
|
selfInfo={selfInfo}
|
||||||
textModelOptions={reportTextModelOptions}
|
dbReady={isDatabaseConnected}
|
||||||
visionModelOptions={reportVisionModelOptions}
|
dbConnecting={isDatabaseConnecting}
|
||||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
onSelectReport={openReport}
|
||||||
reportMessageCount={reportGeneration.reportMessages.length}
|
onCreateReport={openReportConfigure}
|
||||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
onDeleteReport={handleDeleteReport}
|
||||||
rangeState={reportGeneration.rangeState}
|
onOpenSettings={openSettings}
|
||||||
phase={reportGeneration.phase}
|
/>
|
||||||
error={reportGeneration.error}
|
<ReportViewer
|
||||||
generatedImage={reportGeneration.generatedImage}
|
report={selectedReport}
|
||||||
reportPaths={reportGeneration.reportPaths}
|
hasReports={generatedReports.length > 0}
|
||||||
isGenerating={reportGeneration.isGenerating}
|
onBackToConfigure={openReportConfigure}
|
||||||
onSummaryDateRangeChange={setSummaryDateRange}
|
onRegenerate={handleRegenerateReport}
|
||||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
onCopyImage={handleCopyReportImage}
|
||||||
onOpenModelSettings={openModelSettings}
|
onReveal={handleRevealReport}
|
||||||
onTextModelChange={(model) => {
|
onSwitchTemplate={handleSwitchReportTemplate}
|
||||||
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
|
sendTarget={selectedReportContact}
|
||||||
setReportTextModelConfig(model)
|
/>
|
||||||
}}
|
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||||
onVisionModelChange={(model) => {
|
</div>
|
||||||
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
|
) : (
|
||||||
setAiVisionModelConfig(model)
|
<div className="report-page">
|
||||||
}}
|
<ReportSourceSidebar
|
||||||
onGenerate={() => {
|
contacts={contacts}
|
||||||
reportGeneration.resetGenerationStatus()
|
selectedContact={reportSourceContact}
|
||||||
void reportGeneration.generate()
|
selfInfo={selfInfo}
|
||||||
}}
|
dbReady={isDatabaseConnected}
|
||||||
onCloseResult={reportGeneration.closeResult}
|
dbConnecting={isDatabaseConnecting}
|
||||||
onCopyImage={reportGeneration.copyImage}
|
onSelectContact={handleSelectReportSource}
|
||||||
onRevealReport={reportGeneration.revealReport}
|
onOpenSettings={openSettings}
|
||||||
onViewResult={openReportResult}
|
/>
|
||||||
hasReportResult={generatedReports.length > 0}
|
<AiReportWorkspace
|
||||||
templateId={reportGeneration.templateId}
|
sourceContact={reportSourceContact}
|
||||||
onTemplateIdChange={reportGeneration.setTemplateId}
|
summaryDateRange={summaryDateRange}
|
||||||
memberNamePreference={reportGeneration.memberNamePreference}
|
summaryMessageTypes={summaryMessageTypes}
|
||||||
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
|
modelConfig={reportTextModelConfig}
|
||||||
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
|
visionModelConfig={aiVisionModelConfig}
|
||||||
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
|
textModelOptions={reportTextModelOptions}
|
||||||
/>
|
visionModelOptions={reportVisionModelOptions}
|
||||||
<ReportTaskStatusPanel
|
rangeMessageCount={reportGeneration.rangeMessages.length}
|
||||||
phase={reportGeneration.phase}
|
reportMessageCount={reportGeneration.reportMessages.length}
|
||||||
error={reportGeneration.error}
|
messageTypeCounts={reportGeneration.messageTypeCounts}
|
||||||
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
rangeState={reportGeneration.rangeState}
|
||||||
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
phase={reportGeneration.phase}
|
||||||
preparationProgress={reportGeneration.preparationProgress}
|
error={reportGeneration.error}
|
||||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
generatedImage={reportGeneration.generatedImage}
|
||||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
reportPaths={reportGeneration.reportPaths}
|
||||||
currentModel={reportTextModelConfig}
|
isGenerating={reportGeneration.isGenerating}
|
||||||
onRetry={(model) => void reportGeneration.retry(model)}
|
onSummaryDateRangeChange={setSummaryDateRange}
|
||||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
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>
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const renderCurrentWorkspace = (): React.ReactElement => {
|
const renderCurrentWorkspace = (): React.ReactElement => {
|
||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
@@ -1911,6 +1963,7 @@ function App(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
onNotice={setReportNotice}
|
onNotice={setReportNotice}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||||
onAppearanceChange={handleAppearanceChange}
|
onAppearanceChange={handleAppearanceChange}
|
||||||
onSwitchAccount={handleSwitchAccount}
|
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 { AboutPage } from './pages/AboutPage'
|
||||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||||
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
||||||
|
import { PersonalWechatSendPage } from './pages/PersonalWechatSendPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function SettingsWorkspace({
|
|||||||
onAIRuntimeChange,
|
onAIRuntimeChange,
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onOpenTextToSpeechSettings,
|
||||||
onAppearanceChange,
|
onAppearanceChange,
|
||||||
onSwitchAccount
|
onSwitchAccount
|
||||||
}: {
|
}: {
|
||||||
@@ -50,6 +52,7 @@ export function SettingsWorkspace({
|
|||||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
onOpenTextToSpeechSettings?: () => void
|
||||||
onAppearanceChange: (settings: {
|
onAppearanceChange: (settings: {
|
||||||
theme: 'system' | 'light' | 'dark'
|
theme: 'system' | 'light' | 'dark'
|
||||||
compactMode: boolean
|
compactMode: boolean
|
||||||
@@ -92,6 +95,13 @@ export function SettingsWorkspace({
|
|||||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
case 'voice-recognition':
|
case 'voice-recognition':
|
||||||
return <VoiceRecognitionPage onNotice={onNotice} />
|
return <VoiceRecognitionPage onNotice={onNotice} />
|
||||||
|
case 'wechat-send':
|
||||||
|
return (
|
||||||
|
<PersonalWechatSendPage
|
||||||
|
onNotice={onNotice}
|
||||||
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'text-to-speech':
|
case 'text-to-speech':
|
||||||
return <TextToSpeechPage onNotice={onNotice} />
|
return <TextToSpeechPage onNotice={onNotice} />
|
||||||
case 'recall-protection':
|
case 'recall-protection':
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '智能能力',
|
label: '智能能力',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'wechat-send', label: '微信发送' },
|
||||||
{ id: 'voice-recognition', label: '语音转文字' },
|
{ id: 'voice-recognition', label: '语音转文字' },
|
||||||
{ id: 'text-to-speech', label: '文字转语音' },
|
{ id: 'text-to-speech', label: '文字转语音' },
|
||||||
{ id: 'ai-model', label: 'AI 模型' }
|
{ id: 'ai-model', label: 'AI 模型' }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
|||||||
| 'database-key'
|
| 'database-key'
|
||||||
| 'image-key'
|
| 'image-key'
|
||||||
| 'voice-recognition'
|
| 'voice-recognition'
|
||||||
|
| 'wechat-send'
|
||||||
| 'text-to-speech'
|
| 'text-to-speech'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
| 'recall-protection'
|
| '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);
|
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 {
|
.report-center-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 292px minmax(420px, 1fr) 304px;
|
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 type ScheduledReportExecutionStatus = 'running' | 'success' | 'failed'
|
||||||
|
|
||||||
export interface ScheduledReportTask {
|
export interface ScheduledReportTask {
|
||||||
@@ -8,6 +19,10 @@ export interface ScheduledReportTask {
|
|||||||
group: string
|
group: string
|
||||||
scheduleTime: string
|
scheduleTime: string
|
||||||
reportRange: ScheduledReportRange
|
reportRange: ScheduledReportRange
|
||||||
|
messageTypes?: ScheduledReportMessageType[]
|
||||||
|
templateId?: SelectableReportTemplateId
|
||||||
|
memberNameMode?: ScheduledReportMemberNameMode
|
||||||
|
timeoutSeconds?: number
|
||||||
/** Current implementation targets one specified WeChat group. */
|
/** Current implementation targets one specified WeChat group. */
|
||||||
target: string
|
target: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -35,6 +50,10 @@ export interface ScheduledReportCreateInput {
|
|||||||
group: string
|
group: string
|
||||||
scheduleTime: string
|
scheduleTime: string
|
||||||
reportRange?: ScheduledReportRange
|
reportRange?: ScheduledReportRange
|
||||||
|
messageTypes?: ScheduledReportMessageType[]
|
||||||
|
templateId?: SelectableReportTemplateId
|
||||||
|
memberNameMode?: ScheduledReportMemberNameMode
|
||||||
|
timeoutSeconds?: number
|
||||||
target?: string
|
target?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
@@ -42,7 +61,16 @@ export interface ScheduledReportCreateInput {
|
|||||||
export type ScheduledReportUpdateInput = Partial<
|
export type ScheduledReportUpdateInput = Partial<
|
||||||
Pick<
|
Pick<
|
||||||
ScheduledReportCreateInput,
|
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))
|
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 = () => ({
|
const startupCache = () => ({
|
||||||
self: fixture.self,
|
self: fixture.self,
|
||||||
contacts,
|
contacts,
|
||||||
@@ -299,6 +311,106 @@ handle('wechat-personal:getStatus', () => ({
|
|||||||
canSendVoice: true,
|
canSendVoice: true,
|
||||||
message: '个人微信已绑定'
|
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', () => ({
|
handle('wechat-share:getConfig', () => ({
|
||||||
success: true,
|
success: true,
|
||||||
configured: true,
|
configured: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user