From c835d5c570f504d1de2e2829d0cffe47b4c5db68 Mon Sep 17 00:00:00 2001 From: Wxw-Gu <84499436+Wxw-Gu@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5?= =?UTF-8?q?=E5=BD=92=E5=88=B0=E5=AE=9A=E5=88=B6=E5=8C=96=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/App.tsx | 12 + .../reports/MessageTypeSelector.tsx | 10 +- .../reports/ScheduledReportsWorkspace.tsx | 14 +- .../automation/AutomationRuleTypeTabs.tsx | 36 +- .../automation/AutomationWorkspace.tsx | 173 ++++- .../src/features/automation/RuleListPanel.tsx | 48 +- .../automation/ScheduledReportEditor.tsx | 439 ++++++++++++ .../automation/ScheduledReportPreview.tsx | 153 +++++ .../automation/ScheduledReportRuleList.tsx | 160 +++++ .../model/scheduled-report-preview.ts | 291 ++++++++ src/renderer/src/styles/automation.scss | 192 ++++++ .../scheduled-report-automation.test.tsx | 629 ++++++++++++++++++ tests/e2e/support/electron-main.cjs | 50 +- 13 files changed, 2182 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/features/automation/ScheduledReportEditor.tsx create mode 100644 src/renderer/src/features/automation/ScheduledReportPreview.tsx create mode 100644 src/renderer/src/features/automation/ScheduledReportRuleList.tsx create mode 100644 src/renderer/src/features/automation/model/scheduled-report-preview.ts create mode 100644 tests/component/scheduled-report-automation.test.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f789f58..8cc2e47 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1677,6 +1677,16 @@ function App(): React.ReactElement { setActivePage('automation') } + /** + * 「日报 → 定时日报 → 在自动化中配置」。 + * + * 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。 + */ + const openScheduledReportAutomation = (): void => { + setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() }) + setActivePage('automation') + } + /** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */ const openExitMonitorGroups = (): void => { setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() }) @@ -2009,6 +2019,7 @@ function App(): React.ReactElement { onNotice={(message, variant) => toast({ description: message, variant, duration: 3200 }) } + onOpenAutomation={openScheduledReportAutomation} /> ) : reportWorkspaceView === 'result' ? (
@@ -2136,6 +2147,7 @@ function App(): React.ReactElement { onOpenSendSettings={openWechatSendSettings} onOpenExitMonitorGroups={openExitMonitorGroups} openRuleRequest={automationOpenRuleRequest} + onOpenModelSettings={openModelSettings} /> ) case 'agent-hub': diff --git a/src/renderer/src/components/reports/MessageTypeSelector.tsx b/src/renderer/src/components/reports/MessageTypeSelector.tsx index 07b11ca..1c42dfb 100644 --- a/src/renderer/src/components/reports/MessageTypeSelector.tsx +++ b/src/renderer/src/components/reports/MessageTypeSelector.tsx @@ -4,7 +4,13 @@ import { Checkbox } from '../ui' interface MessageTypeSelectorProps { value: SummaryMessageType[] - counts: Record + /** + * 每类消息的条数。 + * + * 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计, + * 不传就不显示数字,而不是显示一排假的 0。 + */ + counts?: Record disabled: boolean onChange: (value: SummaryMessageType[]) => void } @@ -49,7 +55,7 @@ export function MessageTypeSelector({ {option.label} {option.description} - {counts[option.value]} + {counts ? {counts[option.value]} : null} ))}
diff --git a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx index 77a8db0..a8b1632 100644 --- a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx +++ b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx @@ -58,6 +58,8 @@ interface ScheduledReportsWorkspaceProps { onOpenAgentHub: () => void onOpenModelSettings?: () => void onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void + /** 纯导航:跳到「自动化 → 规则 → 定时日报」;不改动本页任何数据。 */ + onOpenAutomation?: () => void } const rangeLabel = (range: ScheduledReportRange): string => @@ -578,7 +580,8 @@ export function ScheduledReportsWorkspace({ onOpenWechatSettings, onOpenAgentHub, onOpenModelSettings, - onNotice + onNotice, + onOpenAutomation }: ScheduledReportsWorkspaceProps): React.ReactElement { const [capability, setCapability] = React.useState(null) const [capabilityError, setCapabilityError] = React.useState(false) @@ -862,6 +865,15 @@ export function ScheduledReportsWorkspace({

定时日报

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

+ {onOpenAutomation ? ( + + ) : null}
diff --git a/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx index 7b0635c..a671741 100644 --- a/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx +++ b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx @@ -2,6 +2,30 @@ import * as React from 'react' import { SegmentedControl, SegmentedControlItem } from '../../components/ui' import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation' +/** + * Tab 上能出现的规则类型。 + * + * `scheduled_report` **只存在于这一层**:真实 `AutomationRule.ruleType` + * 仍然只有 `daily_report` / `leave_notification` + * —— 不往真实 schema 里塞一个还不存在的类型。 + */ +export type AutomationRuleTabType = AutomationRuleType | 'scheduled_report' + +/** + * Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。 + */ +const RULE_TABS: AutomationRuleTabType[] = [ + 'daily_report', + 'scheduled_report', + 'leave_notification' +] + +const TAB_LABELS: Record = { + daily_report: AUTOMATION_RULE_TYPE_LABELS.daily_report, + scheduled_report: '定时日报', + leave_notification: AUTOMATION_RULE_TYPE_LABELS.leave_notification +} + /** * AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。 * @@ -12,26 +36,24 @@ import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../. * 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。 */ -const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'leave_notification'] - export function AutomationRuleTypeTabs({ value, onValueChange }: { - value: AutomationRuleType - onValueChange: (next: AutomationRuleType) => void + value: AutomationRuleTabType + onValueChange: (next: AutomationRuleTabType) => void }): React.ReactElement { return (
切换规则类型 onValueChange(next as AutomationRuleType)} + onValueChange={(next) => onValueChange(next as AutomationRuleTabType)} aria-label="切换规则类型" > - {RULE_TYPES.map((type) => ( + {RULE_TABS.map((type) => ( - {AUTOMATION_RULE_TYPE_LABELS[type]} + {TAB_LABELS[type]} ))} diff --git a/src/renderer/src/features/automation/AutomationWorkspace.tsx b/src/renderer/src/features/automation/AutomationWorkspace.tsx index 9acf834..61aa3d9 100644 --- a/src/renderer/src/features/automation/AutomationWorkspace.tsx +++ b/src/renderer/src/features/automation/AutomationWorkspace.tsx @@ -5,7 +5,6 @@ import { type AutomationExecution, type AutomationRule, type AutomationRuleDraft, - type AutomationRuleType, type AutomationStatusSummary } from '../../../../shared/automation' import { @@ -19,7 +18,21 @@ import { RuleListPanel } from './RuleListPanel' import { RuleEditorPanel } from './RuleEditorPanel' import { ExecutionLogPanel } from './ExecutionLogPanel' import { ExecutionDetailDrawer } from './ExecutionDetailDrawer' -import { AutomationRuleTypeTabs } from './AutomationRuleTypeTabs' +import { + AutomationRuleTypeTabs, + type AutomationRuleTabType +} from './AutomationRuleTypeTabs' +import { ScheduledReportEditor } from './ScheduledReportEditor' +import { ScheduledReportRuleList } from './ScheduledReportRuleList' +import type { ScheduledReportTask } from '../../../../shared/scheduled-report' +import { + createEmptyScheduledReportDraft, + createDraftFromTask, + formatNextRunAt, + loadScheduledReportTasks, + resolveGroupDisplayName, + type ScheduledReportDraft +} from './model/scheduled-report-preview' import { LeaveNotificationEditor, type LeaveNotificationSaveInput @@ -42,7 +55,7 @@ type AutomationTab = 'rules' | 'logs' /** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */ export interface AutomationOpenRuleRequest { - ruleType: AutomationRuleType + ruleType: AutomationRuleTabType /** 每次点击都要能重新触发,所以带一个自增/时间戳。 */ requestId: number } @@ -53,6 +66,8 @@ export interface AutomationWorkspaceProps { onOpenSendSettings?: () => void /** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */ onOpenExitMonitorGroups?: () => void + /** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */ + onOpenModelSettings?: () => void /** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */ openRuleRequest?: AutomationOpenRuleRequest | null } @@ -60,13 +75,15 @@ export interface AutomationWorkspaceProps { interface EditorState { mode: 'create' | 'edit' rule: AutomationRule | null - ruleType: AutomationRuleType + /** 用 Tab 类型:定时日报只在本层存在,不进真实 `AutomationRule`。 */ + ruleType: AutomationRuleTabType } export function AutomationWorkspace({ dbReady, onOpenSendSettings, onOpenExitMonitorGroups, + onOpenModelSettings, openRuleRequest }: AutomationWorkspaceProps): React.ReactElement { const { toast } = useToast() @@ -152,12 +169,6 @@ export function AutomationWorkspace({ const leaveNotificationRule = rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null - /** 「正在编辑」显示的名字(与保存目标同源)。 */ - const editorTargetName = React.useMemo((): string => { - if (!editor) return '' - if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知' - return dailyReportTarget?.name ?? '新建自动化' - }, [editor, leaveNotificationRule, dailyReportTarget]) /** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */ const leaveContactName = React.useMemo((): string => { @@ -183,6 +194,102 @@ export function AutomationWorkspace({ ) }, [rules]) + /* + * ---------- 定时日报(只读)---------- + * + * 数据是**只读**拿到的真实任务;所有写操作(保存 / 立即执行 / 启停 / 删除) + * 一律不落盘、不调 scheduler、不生成日报。详见 `model/scheduled-report-preview.ts`。 + */ + const [scheduledTasks, setScheduledTasks] = React.useState([]) + const [scheduledLoading, setScheduledLoading] = React.useState(true) + /** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */ + const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>( + null + ) + const [scheduledDraft, setScheduledDraft] = React.useState(null) + + /** 「正在编辑」显示的名字(与保存目标同源)。 */ + const editorTargetName = React.useMemo((): string => { + if (!editor) return '' + if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知' + if (editor.ruleType === 'scheduled_report') { + return scheduledDraft?.name?.trim() || '新建定时日报' + } + return dailyReportTarget?.name ?? '新建自动化' + }, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft]) + + React.useEffect(() => { + let disposed = false + setScheduledLoading(true) + void loadScheduledReportTasks() + .then((tasks) => { + if (!disposed) setScheduledTasks(tasks) + }) + .finally(() => { + if (!disposed) setScheduledLoading(false) + }) + return () => { + disposed = true + } + }, []) + + /** 首页汇总卡:数量 + 最近一次执行时间(加载中先不显示,避免闪一个"0 条")。 */ + const scheduledSummary = React.useMemo(() => { + if (scheduledLoading) return null + const running = scheduledTasks.filter((task) => task.enabled) + const nextRunAt = running + .map((task) => task.nextRunAt) + .filter((value) => Boolean(value)) + .sort()[0] + return { + total: scheduledTasks.length, + running: running.length, + nextRunLabel: nextRunAt ? formatNextRunAt(nextRunAt) : '—' + } + }, [scheduledTasks, scheduledLoading]) + + /** 群标识 → 显示名(列表与预览共用,保证任何地方都不出现裸 id)。 */ + const resolveScheduledGroupDisplay = React.useCallback( + (raw: string): string => resolveGroupDisplayName(raw, groups), + [groups] + ) + + const PREVIEW_NOTICE = 'UI 预览模式,配置暂未保存' + + const openScheduledEditor = (task: ScheduledReportTask | null): void => { + setScheduledDraft(task ? createDraftFromTask(task) : createEmptyScheduledReportDraft()) + setScheduledEditor({ taskId: task?.id ?? null }) + } + + const closeScheduledEditor = (): void => { + setScheduledEditor(null) + setScheduledDraft(null) + } + + /** 保存:只提示,绝不 create/update。 */ + const handleScheduledSave = (): void => { + toast({ description: PREVIEW_NOTICE, duration: 3200 }) + closeScheduledEditor() + } + + /** 立即执行:不碰 scheduler / 生成 / 发送。 */ + const handleScheduledRunNow = (_task: ScheduledReportTask): void => { + toast({ description: 'UI 预览模式,不会执行真实日报任务', duration: 3200 }) + } + + /** 启停:只改本地列表,不写 store。 */ + const handleScheduledToggle = (task: ScheduledReportTask, enabled: boolean): void => { + setScheduledTasks((current) => + current.map((item) => (item.id === task.id ? { ...item, enabled } : item)) + ) + toast({ description: PREVIEW_NOTICE, duration: 3200 }) + } + + /** 删除:直接提示,避免用户以为任务真被删了。 */ + const handleScheduledDelete = (_task: ScheduledReportTask): void => { + toast({ description: 'UI 预览模式,不会删除现有任务', duration: 3200 }) + } + const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise => { setBusyRuleId(rule.id) const updated = await automationApi.setRuleEnabled(rule.id, enabled) @@ -397,6 +504,13 @@ export function AutomationWorkspace({ value={editor.ruleType} onValueChange={(next) => { if (next === editor.ruleType) return + // 离开定时日报时清掉它的编辑态,下次进来回到列表。 + closeScheduledEditor() + if (next === 'scheduled_report') { + // 定时日报是**多条**规则:先给列表,不直接进编辑器。 + setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' }) + return + } if (next === 'leave_notification') { // 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。 setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' }) @@ -418,9 +532,11 @@ export function AutomationWorkspace({ 编辑目标必须**可见**。 保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。 */} -

- 正在编辑:{editorTargetName} -

+ {editor.ruleType !== 'scheduled_report' || scheduledEditor ? ( +

+ 正在编辑:{editorTargetName} +

+ ) : null} {editor.ruleType === 'daily_report' ? ( /* @@ -444,6 +560,33 @@ export function AutomationWorkspace({ onSave={(draft) => void handleSave(draft)} /> ) + ) : editor.ruleType === 'scheduled_report' ? ( + scheduledEditor && scheduledDraft ? ( + + ) : ( + openScheduledEditor(null)} + onEdit={openScheduledEditor} + onRunNow={handleScheduledRunNow} + onToggle={handleScheduledToggle} + onDelete={handleScheduledDelete} + /> + ) ) : leaveNotificationRule ? ( + setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' }) + } onToggle={(rule, enabled) => void handleToggle(rule, enabled)} onEdit={(rule) => { // 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。 diff --git a/src/renderer/src/features/automation/RuleListPanel.tsx b/src/renderer/src/features/automation/RuleListPanel.tsx index 6198e15..c0ec88e 100644 --- a/src/renderer/src/features/automation/RuleListPanel.tsx +++ b/src/renderer/src/features/automation/RuleListPanel.tsx @@ -29,18 +29,32 @@ export interface LeaveNotificationCardModel { contactName: string } +/** + * 定时日报在首页的**汇总卡**数据。 + * + * 它是多条规则,不能像 singleton 那样当成一条展示(也不能给它一个启停开关)。 + */ +export interface ScheduledReportCardModel { + total: number + running: number + nextRunLabel: string +} + export interface RuleListPanelProps { rules: AutomationRule[] groups: Array<{ id: string; name: string }> loading: boolean busyRuleId: string | null leaveNotification: LeaveNotificationCardModel | null + scheduledReport: ScheduledReportCardModel | null onToggle: (rule: AutomationRule, enabled: boolean) => void onEdit: (rule: AutomationRule) => void onDelete: (rule: AutomationRule) => void onCreate: () => void /** 打开「退群通知」规则类型。 */ onEditLeaveNotification: () => void + /** 打开「定时日报」规则类型(多条,所以是"管理"而不是"编辑")。 */ + onManageScheduledReport: () => void } /** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */ @@ -72,11 +86,13 @@ export function RuleListPanel({ loading, busyRuleId, leaveNotification, + scheduledReport, onToggle, onEdit, onDelete, onCreate, - onEditLeaveNotification + onEditLeaveNotification, + onManageScheduledReport }: RuleListPanelProps): React.ReactElement { // 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。 const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID) @@ -207,6 +223,36 @@ export function RuleListPanel({
) : null} + {scheduledReport ? ( +
+
+
+

定时日报

+ {/* 汇总卡:它代表多条规则,所以只报数量,不给单个启停开关。 */} + 0 ? 'on' : 'off'}`} + > + {scheduledReport.total} 条规则 · {scheduledReport.running} 条运行中 + +
+
+
+
触发
+
按设定时间
+
+
+
下次执行
+
{scheduledReport.nextRunLabel}
+
+
+
+
+ +
+
+ ) : null} )} diff --git a/src/renderer/src/features/automation/ScheduledReportEditor.tsx b/src/renderer/src/features/automation/ScheduledReportEditor.tsx new file mode 100644 index 0000000..481b9d4 --- /dev/null +++ b/src/renderer/src/features/automation/ScheduledReportEditor.tsx @@ -0,0 +1,439 @@ +import * as React from 'react' + +import { + Button, + Input, + RadioGroup, + RadioGroupItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch +} from '../../components/ui' +import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector' +import { + SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, + SCHEDULED_REPORT_RANGE_OPTIONS, + SCHEDULED_REPORT_TARGET_OPTIONS, + SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS, + composeScheduleTime, + formatScheduledSlot, + parseScheduleTime, + resolveGroupDisplayName, + type ScheduledReportDraft, + type ScheduledReportTargetType +} from './model/scheduled-report-preview' +import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker' +import { ScheduledReportPreview } from './ScheduledReportPreview' + +/** + * ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。 + * + * 三段:什么时候触发 / 生成什么日报 / 生成后发送到哪里。 + * + * Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 / + * 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。 + * + * 草稿只存在 renderer local state,保存不落盘(由父层提示)。 + */ + +export interface ScheduledReportEditorProps { + initialDraft: ScheduledReportDraft + /** 可选群聊(自动化既有的群列表,与其它规则类型同一来源)。 */ + groups: Array<{ id: string; name: string }> + /** 「指定好友」的候选项(与退群通知共用同一个来源与组件)。 */ + sendableContacts: Array<{ id: string; name: string }> + saving: boolean + /** 返回「定时日报」规则列表(**不是**退回自动化首页)。 */ + onBack: () => void + onCancel: () => void + onSave: (draft: ScheduledReportDraft) => void + /** 复用现有的模型配置入口。 */ + onOpenModelSettings?: () => void +} + +export function ScheduledReportEditor({ + initialDraft, + groups, + sendableContacts, + saving, + onBack, + onCancel, + onSave, + onOpenModelSettings +}: ScheduledReportEditorProps): React.ReactElement { + const [draft, setDraft] = React.useState(initialDraft) + const [groupFilter, setGroupFilter] = React.useState('') + + const patch = (next: Partial): void => + setDraft((current) => ({ ...current, ...next })) + + /* + * 时间的两个输入框保留**本地文本态**。 + * 受控回写会在输入过程中把中间态规范化掉,用户就永远拼不出想要的数字; + * 规范化推迟到失焦。 + */ + const [hourText, setHourText] = React.useState(() => + String(parseScheduleTime(initialDraft.scheduleTime)[0]).padStart(2, '0') + ) + const [minuteText, setMinuteText] = React.useState(() => + String(parseScheduleTime(initialDraft.scheduleTime)[1]).padStart(2, '0') + ) + + const commitHour = (raw: string): void => { + const digits = raw.replace(/\D/g, '').slice(0, 2) + setHourText(digits) + if (digits === '') return + const value = Number(digits) + if (Number.isNaN(value)) return + patch({ scheduleTime: composeScheduleTime(value, parseScheduleTime(draft.scheduleTime)[1]) }) + } + + const commitMinute = (raw: string): void => { + const digits = raw.replace(/\D/g, '').slice(0, 2) + setMinuteText(digits) + if (digits === '') return + const value = Number(digits) + if (Number.isNaN(value)) return + patch({ scheduleTime: composeScheduleTime(parseScheduleTime(draft.scheduleTime)[0], value) }) + } + + /* + * 候选群 = 自动化现有的群列表 **并上** 当前任务已在用的来源群。 + * + * 任务里存的群标识与群列表是两个数据源,不保证一致;不并的话, + * 编辑已有任务会出现"当前来源群在列表里找不到、于是没有任何一项被选中"。 + */ + const groupOptions = React.useMemo(() => { + const known = new Set(groups.map((group) => group.name)) + // 去重:不去重会生成两条同 id 的候选项,React 重复 key 会让渲染错乱。 + const extras = Array.from( + new Set( + [initialDraft.group, initialDraft.target].filter( + (value) => Boolean(value) && !known.has(value) + ) + ) + ).map((value) => ({ id: `current:${value}`, name: value })) + return [...groups, ...extras] + }, [groups, initialDraft.group, initialDraft.target]) + + const visibleGroups = React.useMemo(() => { + const needle = groupFilter.trim().toLowerCase() + if (!needle) return groupOptions + return groupOptions.filter( + (group) => + group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle) + ) + }, [groupOptions, groupFilter]) + + const selectedContactName = + sendableContacts.find((contact) => contact.id === draft.targetContactId)?.name ?? '' + + const selectTargetType = (next: ScheduledReportTargetType): void => { + // 「发送到日报来源群」的目标跟随来源群;其余目标清空,避免看起来已经选好了。 + patch({ + targetType: next, + target: next === 'source_chat' ? draft.group : '', + targetContactId: next === 'contact' ? draft.targetContactId : '' + }) + } + + return ( +
+ + +
+
+

{draft.name.trim() || '新建定时日报'}

+

按设定时间自动生成日报,并发送到指定微信会话。

+
+ 启用这条自动化 + patch({ enabled: checked })} + aria-label="启用这条自动化" + /> +
+
+
+ + +
+
+ +
+
+
+
+

1 · 什么时候触发

+
+ +
+
+ 执行频率 + 每天 + 当前定时日报只支持每天执行一次。 +
+
+ +
+
+ 执行时间 +
+ commitHour(event.target.value)} + onBlur={() => + setHourText(String(parseScheduleTime(draft.scheduleTime)[0]).padStart(2, '0')) + } + /> + + commitMinute(event.target.value)} + onBlur={() => + setMinuteText( + String(parseScheduleTime(draft.scheduleTime)[1]).padStart(2, '0') + ) + } + /> +
+
+
+ +
+
+ 预计下次执行 + + {formatScheduledSlot(draft.scheduleTime)} + +
+
+
+ + {/* ---------- 2 · 完整日报配置(与旧「定时日报」对齐,一项不删)---------- */} +
+
+

2 · 生成什么日报

+
+ +
+ 任务名称 + patch({ name: event.target.value })} + /> +
+ +
+ 日报来源 + 生成哪个群的日报。 + + patch({ + group: value, + target: draft.targetType === 'source_chat' ? value : draft.target + }) + } + aria-label="日报来源" + className="automation-leave-contact-picker" + > + setGroupFilter(event.target.value)} + placeholder="搜索群聊" + aria-label="搜索群聊" + /> +
+ {visibleGroups.length === 0 ? ( +

没有可选的群聊

+ ) : ( + visibleGroups.map((group) => ( +
+ + +
+ )) + )} +
+
+
+ +
+ 日报范围 + +
+ + {/* 复用旧日报页的 MessageTypeSelector:类型 / 说明 /「至少选择一种」都来自真实定义。 */} + patch({ messageTypes: next })} + /> + +
+ 日报内容 + + 与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、 + 活跃成员、活跃时间分布、关键词和内容密度。 + +
+ +
+
+ 模型配置 + 使用当前日报默认的文字总结和图片理解模型。 +
+ {onOpenModelSettings ? ( + + ) : null} +
+ +
+ 日报模板 + +
+ +
+ 成员名称 + +
+ +
+ 日报生成超时 +
+ { + const next = Number(event.target.value.replace(/\D/g, '').slice(0, 4)) + patch({ timeoutSeconds: Number.isNaN(next) ? draft.timeoutSeconds : next }) + }} + /> + 秒 +
+
+
+ + {/* ---------- 3 · 发送目标四选一(不提供"指定其他群聊")---------- */} +
+
+

3 · 生成后发送到哪里

+
+

+ 生成哪个群的日报,与把日报发到哪里,是两件事。 +

+ + selectTargetType(value as ScheduledReportTargetType)} + aria-label="生成后发送到哪里" + className="automation-leave-targets" + > + {SCHEDULED_REPORT_TARGET_OPTIONS.map((option) => ( +
+ +
+ + {option.description} +
+
+ ))} +
+ + {draft.targetType === 'contact' ? ( +
+ 指定好友 + patch({ targetContactId: id })} + ariaLabel="指定好友" + searchPlaceholder="搜索好友" + emptyText="没有可发送的联系人" + /> +
+ ) : null} + +

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

+
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/features/automation/ScheduledReportPreview.tsx b/src/renderer/src/features/automation/ScheduledReportPreview.tsx new file mode 100644 index 0000000..93c56f2 --- /dev/null +++ b/src/renderer/src/features/automation/ScheduledReportPreview.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' + +import { SUMMARY_TYPE_OPTIONS } from '../../utils/group-report' +import { + formatScheduledSlot, + scheduledReportRangeLabel, + scheduledReportTemplateLabel, + type ScheduledReportDraft, + type ScheduledReportTargetType +} from './model/scheduled-report-preview' + +/** + * ScheduledReportPreview —— 右侧「效果预览」。 + * + * 与左侧表单逐项联动,**绝不执行任何真实动作**: + * 不调 scheduler、不生成日报、不发送、不碰 store。 + * + * 定时日报用「流程」表达比微信气泡准确:一次执行是 + * 定时触发 → 生成日报 → 模型 → 模板 → 发送 的链条,而不是一段对话。 + */ + +export interface ScheduledReportPreviewProps { + draft: ScheduledReportDraft + /** 来源群的**显示名**(草稿里存的可能是内部标识,展示前已在调用方解析)。 */ + groupLabel: string + /** 指定好友的显示名。 */ + contactName: string +} + +export function ScheduledReportPreview({ + draft, + groupLabel, + contactName +}: ScheduledReportPreviewProps): React.ReactElement { + const groupText = groupLabel.trim() || '未选择群聊' + + /** 发送目标的显示文案(四种类型各取真实语义,不回落成内部 id)。 */ + const targetText = React.useMemo((): string => { + const labels: Record = { + source_chat: groupText, + self: '我', + file_transfer: '文件传输助手', + contact: contactName.trim() || '未选择好友' + } + return labels[draft.targetType] + }, [draft.targetType, groupText, contactName]) + + /** + * 纳入的消息类型只做**摘要**:左侧才是详细配置, + * 右侧逐条列出来会把预览拉得很长。 + */ + const messageTypesSummary = React.useMemo((): string => { + if (draft.messageTypes.length === 0) return '未选择' + if (draft.messageTypes.length === SUMMARY_TYPE_OPTIONS.length) return '全部类型' + return draft.messageTypes + .map((value) => SUMMARY_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value) + .join('、') + }, [draft.messageTypes]) + + return ( +
+
+

效果预览

+ 仅预览,不会真实执行 +
+ +
    +
  1. + {draft.scheduleTime} + 定时任务触发 + 每天 {draft.scheduleTime} +
  2. + + + +
  3. + 生成日报 + 来源:{groupText} + 范围:{scheduledReportRangeLabel(draft.reportRange)} + 纳入消息:{messageTypesSummary} +
  4. + + + +
  5. + 模型 + 默认日报模型 +
  6. + + + +
  7. + 模板 + {scheduledReportTemplateLabel(draft.templateId)} +
  8. + + + +
  9. + 发送到 + {targetText} +
  10. +
+ + {/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */} +
+ ) +} diff --git a/src/renderer/src/features/automation/ScheduledReportRuleList.tsx b/src/renderer/src/features/automation/ScheduledReportRuleList.tsx new file mode 100644 index 0000000..3f61fc4 --- /dev/null +++ b/src/renderer/src/features/automation/ScheduledReportRuleList.tsx @@ -0,0 +1,160 @@ +import * as React from 'react' + +import { Button, Spinner, Switch } from '../../components/ui' +import type { ScheduledReportTask } from '../../../../shared/scheduled-report' +import { + formatNextRunAt, + scheduledReportRangeLabel, + scheduledReportTemplateLabel +} from './model/scheduled-report-preview' + +/** + * ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。 + * + * 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则 + * (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。 + * + * 所有写操作(立即执行 / 启停 / 删除)都不落盘,只提示。 + * 数据是**只读**拿到的真实任务(见 `model/scheduled-report-preview.ts`)。 + */ + +export interface ScheduledReportRuleListProps { + tasks: ScheduledReportTask[] + loading: boolean + /** 正在"操作中"的任务 id(仅用于视觉反馈,不产生真实副作用)。 */ + busyTaskId: string | null + /** + * 群标识 → 显示名。 + * + * 任务里存的可能是群名、room id 或 hash;列表**绝不能**把内部标识露给用户。 + */ + resolveGroupDisplay: (raw: string) => string + onCreate: () => void + onEdit: (task: ScheduledReportTask) => void + onRunNow: (task: ScheduledReportTask) => void + onToggle: (task: ScheduledReportTask, enabled: boolean) => void + onDelete: (task: ScheduledReportTask) => void +} + +export function ScheduledReportRuleList({ + tasks, + loading, + busyTaskId, + resolveGroupDisplay, + onCreate, + onEdit, + onRunNow, + onToggle, + onDelete +}: ScheduledReportRuleListProps): React.ReactElement { + const runningCount = tasks.filter((task) => task.enabled).length + + return ( +
+
+
+

定时日报

+ + {tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'} + +
+ +

+ 按设定时间自动生成日报,并发送到指定微信会话。 +

+ +
+ + {tasks.length > 0 ? ( + + 运行中 {runningCount} / {tasks.length} + + ) : null} +
+ + {loading ? ( +
+ + 正在读取定时日报… +
+ ) : tasks.length === 0 ? ( +
+

还没有定时日报

+

创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。

+ +
+ ) : ( + tasks.map((task) => ( +
+
+
+

{task.name}

+ {/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */} + + {task.enabled ? '运行中' : '已暂停'} + +
+
+
+
触发
+
每天 {task.scheduleTime}
+
+
+
日报
+
{scheduledReportRangeLabel(task.reportRange)}
+
+
+
来源
+
{resolveGroupDisplay(task.group) || '未设置'}
+
+
+
模板
+
{scheduledReportTemplateLabel(task.templateId)}
+
+
+
发送到
+ {/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */} +
+ {!task.target || task.target === task.group + ? '日报来源群' + : resolveGroupDisplay(task.target)} +
+
+
+
下次执行
+
{task.enabled ? formatNextRunAt(task.nextRunAt) : '—'}
+
+
+
+
+ onToggle(task, checked)} + aria-label={`${task.name} 启停`} + /> + + + +
+
+ )) + )} +
+
+ ) +} diff --git a/src/renderer/src/features/automation/model/scheduled-report-preview.ts b/src/renderer/src/features/automation/model/scheduled-report-preview.ts new file mode 100644 index 0000000..33b3f4f --- /dev/null +++ b/src/renderer/src/features/automation/model/scheduled-report-preview.ts @@ -0,0 +1,291 @@ +import { + DEFAULT_REPORT_TEMPLATE, + REPORT_TEMPLATES, + SELECTABLE_REPORT_TEMPLATES, + getReportTemplate, + type SelectableReportTemplateId +} from '../../../../../shared/report-templates' +import type { + ScheduledReportMemberNameMode, + ScheduledReportRange, + ScheduledReportTask +} from '../../../../../shared/scheduled-report' +import { SUMMARY_TYPE_OPTIONS, type SummaryMessageType } from '../../../utils/group-report' + +/** + * 定时日报编辑态的模型与只读数据层。 + * + * 对外唯一的接触面是 `window.api.listScheduledReports`(**只读**): + * 不写、不执行、不动 scheduler / store / schema。 + * + * 所有字段都来自 `shared/scheduled-report.ts` 的真实模型,**不发明后端不存在的选项**: + * - `schedule.type` 只有 `'daily'` ⇒ 界面只给"每天 + 时间"; + * - `target` 是单个群(`type: 'wechat_group'`)⇒ 界面只给群聊目标; + * - `templateId` 真实存在 ⇒ 界面可以给模板选择。 + */ + +/** + * 编辑器的草稿:字段与**旧「定时日报」真实配置**一一对应, + * 没有任何"想象出来的"配置,也没有丢掉任何一项已有能力。 + */ +export interface ScheduledReportDraft { + name: string + /** 每天的执行时间 `HH:mm`,对应真实的 `schedule: { type: 'daily', time }`。 */ + scheduleTime: string + reportRange: ScheduledReportRange + /** 日报来源群:生成**哪个群**的日报。 */ + group: string + /** 纳入的消息类型(真实 `messageTypes`)。 */ + messageTypes: SummaryMessageType[] + /** 成员名称显示方式(真实 `memberNameMode`)。 */ + memberNameMode: ScheduledReportMemberNameMode + /** 日报生成超时(真实 `timeoutSeconds`,旧页面 30–1800 秒)。 */ + timeoutSeconds: number + templateId: SelectableReportTemplateId + /** + * 发送目标的**类型**。 + * + * 这是本层的概念:真实的 `ScheduledReportTask.target` 只是一个字符串 + * (目前只表达"某个微信群")。四选一里的「自己 / 文件传输助手 / 指定好友」 + * 在真实模型里还没有对应的类型化表达 —— 真实迁移时应当照退群通知的做法, + * 给 target 一个带类型的模型与解析器。 + */ + targetType: ScheduledReportTargetType + /** `targetType === 'source_chat'` 时的目标群(= 来源群,旧页面就是这个语义)。 */ + target: string + /** `targetType === 'contact'` 时的联系人。 */ + targetContactId: string + enabled: boolean +} + +/** 全部消息类型(旧页面默认全选)。 */ +export const ALL_SCHEDULED_REPORT_MESSAGE_TYPES: SummaryMessageType[] = SUMMARY_TYPE_OPTIONS.map( + (option) => option.value +) + +/** 成员名称选项(与旧页面一致)。 */ +export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{ + value: ScheduledReportMemberNameMode + label: string +}> = [ + { value: 'groupNickname', label: '群昵称' }, + { value: 'wechatNickname', label: '微信昵称' }, + { value: 'remark', label: '通讯录备注' } +] + +/** + * 发送目标四选一。 + * + * 第一项与退群通知不同:定时日报没有"当前消息所在群"这个概念, + * 所以是「日报来源群」。刻意**不提供"指定其他群聊"** —— + * 生成 A 群日报却发到 B 群是很容易让人误解的配置。 + */ +export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact' + +export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{ + type: ScheduledReportTargetType + label: string + description: string +}> = [ + { + type: 'file_transfer', + label: '文件传输助手', + description: '将生成的日报发送到文件传输助手。' + }, + { + type: 'source_chat', + label: '发送到日报来源群', + description: '把生成的日报发送回用于生成日报的那个群。' + }, + { + type: 'self', + label: '发给自己', + description: '将生成的日报发送到当前登录微信账号自己的会话。' + }, + { + type: 'contact', + label: '指定好友', + description: '将生成的日报发送给一个指定联系人。' + } +] + +/** 新建时的默认发送目标:文件传输助手(不会误打扰群聊)。 */ +export const DEFAULT_SCHEDULED_REPORT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer' + +/** 模板选项:与旧页面同一套定义(默认模板 + REPORT_TEMPLATES)。 */ +export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{ + value: SelectableReportTemplateId + label: string +}> = [ + { value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name }, + ...REPORT_TEMPLATES.map((template) => ({ + value: template.id, + label: `${template.label} · ${template.name}` + })) +] + +/** 日报范围选项。文案与旧「日报 → 定时日报」页面保持一致。 */ +export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{ + value: ScheduledReportRange + label: string +}> = [ + { value: 'today', label: '今日' }, + { value: 'yesterday', label: '昨日' }, + { value: '7days', label: '近 7 天' }, + { value: 'recent24h', label: '最近24小时' } +] + +/** 可选日报模板(真实定义来自 `report-templates.ts`)。 */ +export const SCHEDULED_REPORT_TEMPLATE_OPTIONS = SELECTABLE_REPORT_TEMPLATES.map((template) => ({ + value: template.id, + label: template.label +})) + +export const DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID: SelectableReportTemplateId = 'v1' + +/** 与旧页面同语义的范围文案(`近 7 天` 的空格等细节刻意保持一致)。 */ +export function scheduledReportRangeLabel(range: ScheduledReportRange): string { + return ( + SCHEDULED_REPORT_RANGE_OPTIONS.find((option) => option.value === range)?.label ?? + SCHEDULED_REPORT_RANGE_OPTIONS[0].label + ) +} + +/** + * 时间展示:`今日 18:30` / `明日 18:30`。 + * + * 这里只服务**本地草稿预览**(用户刚改完时间就要看到结果), + * 所以用"今日/明日"表达相对关系;真实任务的 `nextRunAt` 展示沿用旧页面语义。 + */ +export function formatScheduledSlot(scheduleTime: string, now = new Date()): string { + const [hour, minute] = parseScheduleTime(scheduleTime) + const slot = new Date(now) + slot.setSeconds(0, 0) + slot.setHours(hour, minute) + if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1) + const isToday = slot.toDateString() === now.toDateString() + return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}` +} + +/** 把真实的 `nextRunAt` 格式化成旧页面同款文案。 */ +export function formatNextRunAt(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' })}` +} + +export function parseScheduleTime(value: string): [number, number] { + const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim()) + if (!matched) return [18, 30] + const hour = Math.min(23, Math.max(0, Number(matched[1]))) + const minute = Math.min(59, Math.max(0, Number(matched[2]))) + return [hour, minute] +} + +export function composeScheduleTime(hour: number, minute: number): string { + return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}` +} + +export function createEmptyScheduledReportDraft(): ScheduledReportDraft { + return { + name: '', + scheduleTime: '18:30', + reportRange: 'today', + group: '', + // 与旧页面一致:默认纳入全部消息类型。 + messageTypes: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES], + memberNameMode: 'groupNickname', + timeoutSeconds: 300, + templateId: DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID, + // 默认发到文件传输助手:不会误打扰任何群聊。 + targetType: DEFAULT_SCHEDULED_REPORT_TARGET_TYPE, + target: '', + targetContactId: '', + enabled: true + } +} + +/** 真实任务 → 编辑器草稿(只用于**预览**,保存不落盘)。 */ +export function createDraftFromTask(task: ScheduledReportTask): ScheduledReportDraft { + return { + name: task.name, + scheduleTime: task.scheduleTime, + reportRange: task.reportRange, + group: task.group, + messageTypes: task.messageTypes?.length + ? (task.messageTypes as SummaryMessageType[]) + : [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES], + memberNameMode: task.memberNameMode ?? 'groupNickname', + timeoutSeconds: task.timeoutSeconds ?? 300, + templateId: task.templateId ?? DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID, + // 旧页面里「发送到」恒等于来源群(只读展示),所以历史任务一律按"发回来源群"回显。 + targetType: 'source_chat', + target: task.target, + targetContactId: '', + enabled: task.enabled + } +} + +/** + * 把任务里存的群标识解析成**能给用户看**的名字。 + * + * `ScheduledReportTask.group/target` 的真实类型是 "human-readable group name or room id", + * 实际数据里两种都可能出现(纯数字 room id 与 16 进制 hash)。 + * 直接渲染就会把内部标识暴露给用户,所以这里做一次显示名解析: + * 能对上群名就用群名;明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。 + */ +export function resolveGroupDisplayName( + raw: string, + groups: Array<{ id: string; name: string }> +): string { + const value = String(raw ?? '').trim() + if (!value) return '' + const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value) + if (matched) return matched.name + return looksLikeInternalIdentifier(value) ? '未知群聊' : value +} + +/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */ +function looksLikeInternalIdentifier(value: string): boolean { + return ( + value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value) + ) +} + +/** + * 模板的**展示名**。 + * + * 用 `name`("经典日报")而不是 `label`("默认模板"): + * 同一个模板在列表、预览、选择器里必须是同一个称呼, + * 否则同一份配置会出现两种叫法。 + */ +export function scheduledReportTemplateLabel(templateId?: string): string { + return getReportTemplate(templateId).name +} + +/** + * 只读加载现有定时日报任务。 + * + * **唯一的读取入口**,也是本文件与真实系统唯一的接触面: + * - 不调用 create / update / delete / execute / toggle; + * - 接口不存在或失败时返回空数组,让界面走空态,而不是伪造数据。 + */ +export async function loadScheduledReportTasks(): Promise { + const target = (globalThis as { api?: Record }).api + const loader = target?.listScheduledReports + if (typeof loader !== 'function') return [] + try { + const tasks = await (loader as () => Promise).call(target) + return Array.isArray(tasks) ? (tasks as ScheduledReportTask[]) : [] + } catch { + return [] + } +} + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} diff --git a/src/renderer/src/styles/automation.scss b/src/renderer/src/styles/automation.scss index 2c51939..f50be12 100644 --- a/src/renderer/src/styles/automation.scss +++ b/src/renderer/src/styles/automation.scss @@ -1483,3 +1483,195 @@ font-weight: 600; } } + +/* ---------- 定时日报(只读)---------- */ + +/* 标题下的引导语,与卡片区之间留一点呼吸。 */ +.automation-section-lead { + margin: -2px 0 2px; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; +} + +.automation-scheduled-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin: 6px 0 2px; +} + +/* 「执行时间」的 时:分 —— 两个窄输入 + 冒号。 */ +.automation-scheduled-time { + display: flex; + align-items: center; + gap: 6px; + + input { + width: 56px; + text-align: center; + } + + > span { + color: var(--wxex-text-muted); + font-size: 13px; + } +} + +/* + * 预览里的执行流程:定时触发 → 生成日报 → 发送图片。 + * 定时日报是一次「任务」而不是一段对话,用流程表达比微信气泡准确。 + */ +.automation-preview-flow { + display: grid; + gap: 6px; + margin: 0; + padding: 12px; + list-style: none; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-app); +} + +.automation-preview-flow-step { + display: flex; + flex-direction: column; + gap: 1px; +} + +.automation-preview-flow-time { + color: var(--wxex-primary); + font-size: 15px; + font-weight: 700; + line-height: 20px; +} + +.automation-preview-flow-title { + color: var(--wxex-text-primary); + font-size: 13px; + line-height: 19px; + font-weight: 600; +} + +.automation-preview-flow-step small { + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; +} + +.automation-preview-flow-arrow { + padding-left: 1px; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 12px; +} + +/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */ +.automation-preview-report-card { + display: grid; + gap: 7px; + margin: 10px 0 0; + padding: 12px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); +} + +.automation-preview-report-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--wxex-text-secondary); + font-size: 11px; + line-height: 16px; +} + +.automation-preview-report-line { + height: 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--wxex-text-muted) 22%, transparent); + + &.wide { + width: 82%; + } + + &.short { + width: 46%; + } +} + +.automation-preview-footnote { + margin: 10px 0 0; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 17px; +} + +/* 「日报 → 定时日报」页顶部的入口,跳到「自动化 → 规则 → 定时日报」。 */ +.report-scheduled-automation-entry { + margin-top: 6px; + padding: 0; + border: none; + background: none; + color: var(--wxex-primary); + font-size: 12px; + line-height: 18px; + cursor: pointer; + text-align: left; + + &:hover { + text-decoration: underline; + } +} + +/* ---------- 定时日报编辑器 ---------- */ + +/* 「← 返回定时日报」:回的是规则列表,不是自动化首页。 */ +.automation-editor-back { + align-self: flex-start; + margin-bottom: 2px; + padding: 0; + border: none; + background: none; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; + cursor: pointer; + + &:hover { + color: var(--wxex-primary); + } +} + +/* 模型配置:说明在左,「更改模型」在右。 */ +.automation-model-row { + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.automation-scheduled-timeout { + display: flex; + align-items: center; + gap: 6px; + + input { + width: 84px; + } + + > span { + color: var(--wxex-text-muted); + font-size: 13px; + } +} + +.automation-editor-footnote { + margin: 4px 0 0; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; +} diff --git a/tests/component/scheduled-report-automation.test.tsx b/tests/component/scheduled-report-automation.test.tsx new file mode 100644 index 0000000..1fd699d --- /dev/null +++ b/tests/component/scheduled-report-automation.test.tsx @@ -0,0 +1,629 @@ +import React from 'react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { ToastProvider } from '../../src/renderer/src/components/ui' +import { + AutomationWorkspace, + type AutomationOpenRuleRequest +} from '../../src/renderer/src/features/automation/AutomationWorkspace' +import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace' +import { + createDefaultDailyReportRule, + createDefaultLeaveNotificationRule +} from '../../src/shared/automation' +import type { ScheduledReportTask } from '../../src/shared/scheduled-report' + +/** + * 「自动化 → 规则 → 定时日报」。 + * + * 这一组测试要守住的核心不是"界面长什么样",而是**边界**: + * 任何写操作都不许落到真实定时日报系统上 + * (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。 + */ + +const TASKS: ScheduledReportTask[] = [ + { + id: 'task-1', + name: 'TraceMemo 每日晚报', + group: 'TraceMemo 交流群', + scheduleTime: '18:21', + reportRange: 'today', + target: 'TraceMemo 管理群', + enabled: true, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + nextRunAt: '2026-09-24T10:21:00.000Z' + }, + { + id: 'task-2', + name: '技术交流群日报', + group: '技术交流群', + scheduleTime: '09:00', + reportRange: 'yesterday', + target: '技术交流群', + enabled: false, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + nextRunAt: '2026-09-25T01:00:00.000Z' + } +] + +interface Spies { + createScheduledReport: ReturnType + updateScheduledReport: ReturnType + deleteScheduledReport: ReturnType + setScheduledReportEnabled: ReturnType + runScheduledReportNow: ReturnType + retryScheduledReportSend: ReturnType + testScheduledReportErrorNotification: ReturnType + listScheduledReports: ReturnType +} + +/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */ +function writeSpies(spies: Spies): Array<[string, ReturnType]> { + return [ + ['createScheduledReport', spies.createScheduledReport], + ['updateScheduledReport', spies.updateScheduledReport], + ['deleteScheduledReport', spies.deleteScheduledReport], + ['setScheduledReportEnabled', spies.setScheduledReportEnabled], + ['runScheduledReportNow', spies.runScheduledReportNow], + ['retryScheduledReportSend', spies.retryScheduledReportSend], + ['testScheduledReportErrorNotification', spies.testScheduledReportErrorNotification] + ] +} + +function installApi(tasks: ScheduledReportTask[] = TASKS): Spies { + const spies: Spies = { + listScheduledReports: vi.fn().mockResolvedValue(tasks), + createScheduledReport: vi.fn(), + updateScheduledReport: vi.fn(), + deleteScheduledReport: vi.fn(), + setScheduledReportEnabled: vi.fn(), + runScheduledReportNow: vi.fn(), + retryScheduledReportSend: vi.fn(), + testScheduledReportErrorNotification: vi.fn() + } + + window.api = { + getAutomationStatus: vi.fn().mockResolvedValue({ + listening: true, + listeningDegraded: false, + todayExecutions: 0, + todaySuccesses: 0, + sendCapability: { + supported: true, + ready: true, + canSendText: true, + canSendImage: true, + message: 'ready' + } + }), + listAutomationRules: vi.fn().mockResolvedValue([ + createDefaultDailyReportRule(Date.now()), + createDefaultLeaveNotificationRule(Date.now()) + ]), + listAutomationGroups: vi.fn().mockResolvedValue([ + { id: 'g1', name: 'TraceMemo 交流群' }, + { id: 'g2', name: '技术交流群' }, + { id: 'g3', name: 'TraceMemo 管理群' } + ]), + listAutomationExecutions: vi.fn().mockResolvedValue([]), + listSendableContacts: vi.fn().mockResolvedValue([]), + getGroupExitMonitorState: vi.fn().mockResolvedValue({ + events: [], + enabled: true, + running: true, + nativeMonitorActive: true, + monitoredGroupCount: 2, + monitorSelectionConfigured: true, + monitoredRoomIds: [], + lastReadAt: 0, + unreadCount: 0 + }), + onGroupExitMonitorState: vi.fn(() => () => undefined), + // 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。 + listScheduledReportExecutions: vi.fn().mockResolvedValue([]), + getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null), + getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }), + ...spies + } as unknown as typeof window.api + + return spies +} + +const renderWorkspace = (request?: AutomationOpenRuleRequest): void => { + render( + + {}} + /> + + ) +} + +/** 打开「规则 → 定时日报」。 */ +async function openScheduledTab(): Promise { + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '新建自动化' })) + await user.click(screen.getByRole('radio', { name: '定时日报' })) +} + +describe('自动化 · 规则类型 Tab(定时日报)', () => { + it('Tab 有三项,顺序是 消息 → 时间 → 系统事件', async () => { + installApi() + renderWorkspace() + + await screen.findByText('退群通知') + await userEvent.setup().click(screen.getAllByRole('button', { name: '新建自动化' })[0]) + + const tabs = Array.from( + document.querySelectorAll('.automation-rule-type-tabs [role="radio"]') + ).map((el) => el.textContent?.trim()) + expect(tabs).toEqual(['@我生成日报', '定时日报', '退群通知']) + }) + + it('切到定时日报先给规则列表,不直接进编辑器', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + + expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible() + expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible() + expect(screen.getByText('2 条规则')).toBeVisible() + // 列表态不该出现编辑器的保存按钮。 + expect(screen.queryByRole('button', { name: '保存' })).toBeNull() + }) + + it('列表按真实字段渲染(触发 / 日报 / 来源 / 发送到 / 下次执行)', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + const meta = Array.from(card.querySelectorAll('dl > div')).map((row) => [ + row.querySelector('dt')?.textContent?.trim(), + row.querySelector('dd')?.textContent?.trim() + ]) + + expect(meta).toEqual([ + ['触发', '每天 18:21'], + ['日报', '今日'], + ['来源', 'TraceMemo 交流群'], + ['模板', '经典日报'], + // target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。 + ['发送到', 'TraceMemo 管理群'], + ['下次执行', expect.any(String)] + ]) + // 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。 + const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement + expect(paused.textContent).toContain('已暂停') + expect(within(paused).getByText('—')).toBeVisible() + // 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。 + expect(paused.textContent).toContain('日报来源群') + }) +}) + +describe('定时日报 · 编辑器与预览联动', () => { + it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible() + expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible() + expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible() + expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible() + // 只有一个开关:规则级启停。 + expect(screen.getAllByRole('switch')).toHaveLength(1) + }) + + it('点卡片「编辑」进入编辑器,并带出该任务的值', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + await user.click(within(card).getByRole('button', { name: '编辑' })) + + expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报') + expect(screen.getByLabelText('执行时间小时')).toHaveValue('18') + expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21') + expect( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: 'TraceMemo 交流群' + }) + ).toBeChecked() + }) + + /* + * 任务里存的群名与群列表是两个数据源,不保证一致 + * (群被移出监控、或列表来源变了)。这时**必须回显当前值**, + * 否则编辑已有任务会出现"一串群里没有任何一项被选中"。 + */ + it('来源群不在群列表里时,仍会作为候选项被选中', async () => { + installApi([{ ...TASKS[0], group: '已不在列表的群', target: '已不在列表的群' }]) + renderWorkspace() + await openScheduledTab() + + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + await user.click(within(card).getByRole('button', { name: '编辑' })) + + expect( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '已不在列表的群' + }) + ).toBeChecked() + }) + + it('改执行时间 → 预览与「预计下次执行」同时更新', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + const hourInput = screen.getByLabelText('执行时间小时') + await user.clear(hourInput) + await user.type(hourInput, '07') + const minuteInput = screen.getByLabelText('执行时间分钟') + await user.clear(minuteInput) + await user.type(minuteInput, '05') + + expect(screen.getByTestId('scheduled-next-run').textContent).toMatch(/0?7:05/) + const facts = document.querySelector('.automation-preview-facts')?.textContent ?? '' + expect(facts).toContain('每天 07:05') + }) + + it('改日报来源 → 预览的生成目标跟着变', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + await user.click( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '技术交流群' + }) + ) + + const flow = document.querySelector('.automation-preview-flow')?.textContent ?? '' + expect(flow).toContain('技术交流群') + const facts = document.querySelector('.automation-preview-facts')?.textContent ?? '' + expect(facts).toContain('技术交流群') + }) + + it('发送目标是四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' }) + const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim()) + expect(labels).toEqual(['文件传输助手', '发送到日报来源群', '发给自己', '指定好友']) + expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull() + + // 新建默认 = 文件传输助手(不会误打扰群聊)。 + expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked() + }) + + it('保存只提示,不调用任何真实写接口', async () => { + const spies = installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + await user.click(screen.getByRole('button', { name: '保存' })) + + expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible() + for (const [name, spy] of writeSpies(spies)) { + expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() + } + }) + + it('「立即执行」不触发真实执行', async () => { + const spies = installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + await user.click(within(card).getByRole('button', { name: '立即执行' })) + + expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible() + for (const [name, spy] of writeSpies(spies)) { + expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() + } + }) + + it('toggle 只改本地状态并提示,不写回 store', async () => { + const spies = installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + + await user.click(within(card).getByRole('switch')) + + expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible() + expect(within(card).getByText('已暂停')).toBeVisible() + expect(spies.setScheduledReportEnabled).not.toHaveBeenCalled() + for (const [name, spy] of writeSpies(spies)) { + expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() + } + }) + + it('删除只是提示,不移除任务', async () => { + const spies = installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + await user.click(within(card).getByRole('button', { name: '删除' })) + + expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible() + expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible() + for (const [name, spy] of writeSpies(spies)) { + expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() + } + }) + + it('任务列表为空时给空态与新建入口', async () => { + installApi([]) + renderWorkspace() + await openScheduledTab() + + expect(await screen.findByText('还没有定时日报')).toBeVisible() + expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0) + }) +}) + +describe('定时日报 · 完整日报配置(不丢旧能力)', () => { + it('Section 2 承载旧「定时日报」的全部日报配置', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + expect(screen.getByLabelText('定时日报任务名称')).toBeVisible() + expect(screen.getByRole('radiogroup', { name: '日报来源' })).toBeVisible() + expect(screen.getByLabelText('日报范围')).toBeVisible() + expect(screen.getByLabelText('日报模板')).toBeVisible() + expect(screen.getByLabelText('成员名称')).toBeVisible() + expect(screen.getByLabelText('日报生成超时')).toBeVisible() + expect(screen.getByText('模型配置')).toBeVisible() + expect(screen.getByText('更改模型')).toBeVisible() + expect(screen.getByText('日报内容')).toBeVisible() + }) + + it('纳入的消息类型用旧页面的真实类型与说明(默认全选)', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + expect(screen.getByText('纳入的消息类型')).toBeVisible() + expect(screen.getByText('至少选择一种')).toBeVisible() + + // 七类都来自 SUMMARY_TYPE_OPTIONS,不是新写的一套。 + for (const label of ['文本', '图片', '表情包', '视频', '语音', '分享/引用', '系统消息']) { + expect(screen.getByRole('checkbox', { name: label })).toBeVisible() + } + // 旧页面默认全选。 + expect(screen.getByRole('checkbox', { name: '文本' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: '系统消息' })).toBeChecked() + }) + + it('成员名称保留旧页面的三个选项', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + await user.click(screen.getByLabelText('成员名称')) + expect(await screen.findByRole('option', { name: '群昵称' })).toBeVisible() + expect(screen.getByRole('option', { name: '微信昵称' })).toBeVisible() + expect(screen.getByRole('option', { name: '通讯录备注' })).toBeVisible() + }) +}) + +describe('定时日报 · 返回与四选一发送目标', () => { + it('新建页有「← 返回定时日报」,点击回到规则列表', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + await user.click(screen.getByRole('button', { name: '← 返回定时日报' })) + // 回到「定时日报」列表,而不是自动化首页。 + expect(await screen.findByText('2 条规则')).toBeVisible() + expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked() + }) + + it('编辑页也有返回入口,点击回到规则列表', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement + await user.click(within(card).getByRole('button', { name: '编辑' })) + expect(screen.getByRole('button', { name: '← 返回定时日报' })).toBeVisible() + + await user.click(screen.getByRole('button', { name: '← 返回定时日报' })) + expect(await screen.findByText('2 条规则')).toBeVisible() + }) + + it('只有「指定好友」才展开好友选择器', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) { + await user.click(screen.getByRole('radio', { name: label })) + expect(screen.queryByPlaceholderText('搜索好友')).toBeNull() + } + + await user.click(screen.getByRole('radio', { name: '指定好友' })) + expect(screen.getByPlaceholderText('搜索好友')).toBeVisible() + }) + + it('Preview 随发送目标变化,且「发送到日报来源群」显示来源群名', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + await user.click( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '技术交流群' + }) + ) + + const targetOf = (): string => { + const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div')) + const row = rows.find((item) => item.querySelector('dt')?.textContent === '发送目标') + return row?.querySelector('dd')?.textContent ?? '' + } + + expect(targetOf()).toBe('文件传输助手') + await user.click(screen.getByRole('radio', { name: '发送到日报来源群' })) + expect(targetOf()).toBe('技术交流群') + await user.click(screen.getByRole('radio', { name: '发给自己' })) + expect(targetOf()).toBe('我') + }) + + it('Preview 随来源群与模板变化', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + await user.click( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '技术交流群' + }) + ) + const flow = document.querySelector('.automation-preview-flow')?.textContent ?? '' + expect(flow).toContain('技术交流群') + expect(flow).toContain('经典日报') + + await user.click(screen.getByLabelText('日报模板')) + // 选项文案来自 REPORT_TEMPLATES 的 `label · name`。 + await user.click(await screen.findByRole('option', { name: /Mobile 01/ })) + expect(document.querySelector('.automation-preview-flow')?.textContent).not.toContain('经典日报') + }) +}) + +describe('定时日报 · 不向用户暴露内部标识', () => { + it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => { + // 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制), + // 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。 + const FAKE_ROOM_ID = '12345678@chatroom' + const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef' + + installApi([ + { ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID }, + { ...TASKS[1], group: FAKE_GROUP_HASH } + ]) + renderWorkspace() + await openScheduledTab() + + await screen.findByText('TraceMemo 每日晚报') + const body = document.body.textContent ?? '' + expect(body).not.toContain(FAKE_ROOM_ID) + expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12)) + // 对不上群名时给中性文案,而不是把 id 当名字。 + expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0) + }) +}) + +describe('定时日报 · 首页汇总卡', () => { + it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => { + installApi() + renderWorkspace() + + const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement + expect(card.textContent).toContain('2 条规则 · 1 条运行中') + expect(card.textContent).toContain('按设定时间') + // 汇总卡不该有单个启停开关。 + expect(within(card).queryByRole('switch')).toBeNull() + + await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' })) + expect(await screen.findByText('2 条规则')).toBeVisible() + }) +}) + +describe('定时日报 · 深链与旧页面', () => { + it('深链直接落到「自动化 → 规则 → 定时日报」', async () => { + installApi() + + function Harness(): React.ReactElement { + const [request] = React.useState({ + ruleType: 'scheduled_report', + requestId: 1 + }) + return + } + + render( + + + + ) + + expect(await screen.findByText('2 条规则')).toBeVisible() + expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked() + }) + + it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => { + installApi() + const onOpenAutomation = vi.fn() + + render( + + {}} + onOpenAgentHub={() => {}} + onNotice={() => {}} + onOpenAutomation={onOpenAutomation} + /> + + ) + + expect(screen.getByRole('heading', { name: '定时日报' })).toBeVisible() + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: '在自动化中配置 →' })) + expect(onOpenAutomation).toHaveBeenCalledTimes(1) + }) +}) + +/** 让 `waitFor` 在本文件被使用(空态/异步断言需要)。 */ +describe('定时日报 · 异步加载', () => { + it('加载完成后不再显示 loading', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + + await waitFor(() => expect(screen.queryByText('正在读取定时日报…')).toBeNull()) + }) +}) diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 8a21724..2889e01 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -469,7 +469,55 @@ handle('automation:saveLeaveNotificationRule', (draft) => { return structuredClone(leaveNotificationRule) }) -const scheduledReportTasks = [] +/* + * 定时日报任务。 + * + * 默认**为空**(既有 E2E 依赖这个初始状态)。 + * `WXE_E2E_SCHEDULED_FIXTURE=1` 启动时注入三条任务, + * 让「自动化 → 定时日报」的列表与卡片走**真实读取路径**,而不是在渲染层塞假数据。 + */ +const scheduledReportTasks = + process.env.WXE_E2E_SCHEDULED_FIXTURE === '1' + ? [ + { + id: 'fixture-scheduled-1', + name: 'TraceMemo 每日晚报', + group: 'TraceMemo 交流群', + scheduleTime: '18:21', + reportRange: 'today', + target: 'TraceMemo 管理群', + enabled: true, + createdAt: new Date(fixtureNowMs - 86_400_000).toISOString(), + updatedAt: new Date(fixtureNowMs - 86_400_000).toISOString(), + lastRunAt: new Date(fixtureNowMs - 86_400_000).toISOString(), + nextRunAt: new Date(fixtureNowMs + 3_600_000).toISOString() + }, + { + id: 'fixture-scheduled-2', + name: '技术交流群日报', + group: '技术交流群', + scheduleTime: '09:00', + reportRange: 'yesterday', + target: '技术交流群', + enabled: true, + createdAt: new Date(fixtureNowMs - 172_800_000).toISOString(), + updatedAt: new Date(fixtureNowMs - 172_800_000).toISOString(), + nextRunAt: new Date(fixtureNowMs + 64_800_000).toISOString() + }, + { + id: 'fixture-scheduled-3', + name: 'AI 编程讨论群日报', + group: 'AI 编程讨论群', + scheduleTime: '21:30', + reportRange: 'recent24h', + target: 'TechMemo 归档群', + enabled: false, + createdAt: new Date(fixtureNowMs - 259_200_000).toISOString(), + updatedAt: new Date(fixtureNowMs - 259_200_000).toISOString(), + nextRunAt: new Date(fixtureNowMs + 86_400_000).toISOString() + } + ] + : [] const scheduledReportExecutions = [] const generatedReports = [] let scheduledReportNotificationEnabled = false