feat: 定时日报归到定制化功能下

This commit is contained in:
Wxw-Gu
2026-09-25 17:44:45 +08:00
parent 59872daf52
commit c835d5c570
13 changed files with 2182 additions and 25 deletions
+12
View File
@@ -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' ? (
<div className="report-center-page">
@@ -2136,6 +2147,7 @@ function App(): React.ReactElement {
onOpenSendSettings={openWechatSendSettings}
onOpenExitMonitorGroups={openExitMonitorGroups}
openRuleRequest={automationOpenRuleRequest}
onOpenModelSettings={openModelSettings}
/>
)
case 'agent-hub':
@@ -4,7 +4,13 @@ import { Checkbox } from '../ui'
interface MessageTypeSelectorProps {
value: SummaryMessageType[]
counts: Record<SummaryMessageType, number>
/**
* 每类消息的条数。
*
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
* 不传就不显示数字,而不是显示一排假的 0。
*/
counts?: Record<SummaryMessageType, number>
disabled: boolean
onChange: (value: SummaryMessageType[]) => void
}
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
<b>{option.label}</b>
<small>{option.description}</small>
</span>
<em>{counts[option.value]}</em>
{counts ? <em>{counts[option.value]}</em> : null}
</label>
))}
</div>
@@ -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<PersonalWechatSendCapability | null>(null)
const [capabilityError, setCapabilityError] = React.useState(false)
@@ -862,6 +865,15 @@ export function ScheduledReportsWorkspace({
<div>
<h1>定时日报</h1>
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
{onOpenAutomation ? (
<button
type="button"
className="report-scheduled-automation-entry"
onClick={onOpenAutomation}
>
在自动化中配置 →
</button>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<div className="flex items-center gap-2">
@@ -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<AutomationRuleTabType, string> = {
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 (
<div className="automation-rule-type-tabs">
<span className="automation-rule-type-label">切换规则类型</span>
<SegmentedControl
value={value}
onValueChange={(next) => onValueChange(next as AutomationRuleType)}
onValueChange={(next) => onValueChange(next as AutomationRuleTabType)}
aria-label="切换规则类型"
>
{RULE_TYPES.map((type) => (
{RULE_TABS.map((type) => (
<SegmentedControlItem key={type} value={type}>
{AUTOMATION_RULE_TYPE_LABELS[type]}
{TAB_LABELS[type]}
</SegmentedControlItem>
))}
</SegmentedControl>
@@ -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<ScheduledReportTask[]>([])
const [scheduledLoading, setScheduledLoading] = React.useState(true)
/** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */
const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>(
null
)
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(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<void> => {
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 显示一个、保存改另一个。
*/}
<p className="automation-editing-target" data-testid="automation-editing-target">
正在编辑:<strong>{editorTargetName}</strong>
</p>
{editor.ruleType !== 'scheduled_report' || scheduledEditor ? (
<p className="automation-editing-target" data-testid="automation-editing-target">
正在编辑:<strong>{editorTargetName}</strong>
</p>
) : null}
{editor.ruleType === 'daily_report' ? (
/*
@@ -444,6 +560,33 @@ export function AutomationWorkspace({
onSave={(draft) => void handleSave(draft)}
/>
)
) : editor.ruleType === 'scheduled_report' ? (
scheduledEditor && scheduledDraft ? (
<ScheduledReportEditor
// 换一条任务就重建编辑器,避免把上一条的草稿带过来。
key={scheduledEditor.taskId ?? 'new'}
initialDraft={scheduledDraft}
groups={groups}
sendableContacts={sendableContacts}
saving={saving}
onBack={closeScheduledEditor}
onCancel={closeScheduledEditor}
onSave={handleScheduledSave}
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
/>
) : (
<ScheduledReportRuleList
tasks={scheduledTasks}
loading={scheduledLoading}
busyTaskId={null}
resolveGroupDisplay={resolveScheduledGroupDisplay}
onCreate={() => openScheduledEditor(null)}
onEdit={openScheduledEditor}
onRunNow={handleScheduledRunNow}
onToggle={handleScheduledToggle}
onDelete={handleScheduledDelete}
/>
)
) : leaveNotificationRule ? (
<LeaveNotificationEditor
rule={leaveNotificationRule}
@@ -517,6 +660,10 @@ export function AutomationWorkspace({
}
: null
}
scheduledReport={scheduledSummary}
onManageScheduledReport={() =>
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
}
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
onEdit={(rule) => {
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
@@ -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({
</div>
</article>
) : null}
{scheduledReport ? (
<article className="automation-rule-card">
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>定时日报</h3>
{/* 汇总卡:它代表多条规则,所以只报数量,不给单个启停开关。 */}
<span
className={`automation-rule-state ${scheduledReport.running > 0 ? 'on' : 'off'}`}
>
{scheduledReport.total} 条规则 · {scheduledReport.running} 条运行中
</span>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>按设定时间</dd>
</div>
<div>
<dt>下次执行</dt>
<dd>{scheduledReport.nextRunLabel}</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Button variant="outline" size="sm" onClick={onManageScheduledReport}>
管理定时日报 →
</Button>
</div>
</article>
) : null}
</>
)}
</section>
@@ -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<ScheduledReportDraft>(initialDraft)
const [groupFilter, setGroupFilter] = React.useState('')
const patch = (next: Partial<ScheduledReportDraft>): 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 (
<div className="automation-editor">
<button type="button" className="automation-editor-back" onClick={onBack}>
← 返回定时日报
</button>
<header className="automation-editor-header">
<div>
<h2>{draft.name.trim() || '新建定时日报'}</h2>
<p>按设定时间自动生成日报,并发送到指定微信会话。</p>
<div className="automation-leave-status-row">
<span className="automation-field-label">启用这条自动化</span>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) => patch({ enabled: checked })}
aria-label="启用这条自动化"
/>
</div>
</div>
<div className="automation-editor-actions">
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={() => onSave(draft)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</div>
</header>
<div className="automation-editor-body">
<div className="automation-editor-form">
<section className="automation-section">
<div className="automation-section-heading">
<h3>1 · 什么时候触发</h3>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">执行频率</span>
<span className="automation-static-value">每天</span>
<small>当前定时日报只支持每天执行一次。</small>
</div>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">执行时间</span>
<div className="automation-scheduled-time">
<Input
aria-label="执行时间小时"
inputMode="numeric"
value={hourText}
onChange={(event) => commitHour(event.target.value)}
onBlur={() =>
setHourText(String(parseScheduleTime(draft.scheduleTime)[0]).padStart(2, '0'))
}
/>
<span aria-hidden="true">:</span>
<Input
aria-label="执行时间分钟"
inputMode="numeric"
value={minuteText}
onChange={(event) => commitMinute(event.target.value)}
onBlur={() =>
setMinuteText(
String(parseScheduleTime(draft.scheduleTime)[1]).padStart(2, '0')
)
}
/>
</div>
</div>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">预计下次执行</span>
<span className="automation-static-value" data-testid="scheduled-next-run">
{formatScheduledSlot(draft.scheduleTime)}
</span>
</div>
</div>
</section>
{/* ---------- 2 · 完整日报配置(与旧「定时日报」对齐,一项不删)---------- */}
<section className="automation-section">
<div className="automation-section-heading">
<h3>2 · 生成什么日报</h3>
</div>
<div className="automation-field">
<span className="automation-field-label">任务名称</span>
<Input
aria-label="定时日报任务名称"
value={draft.name}
placeholder="例如:TraceMemo 每日晚报"
onChange={(event) => patch({ name: event.target.value })}
/>
</div>
<div className="automation-field">
<span className="automation-field-label">日报来源</span>
<small>生成哪个群的日报。</small>
<RadioGroup
value={draft.group}
onValueChange={(value) =>
patch({
group: value,
target: draft.targetType === 'source_chat' ? value : draft.target
})
}
aria-label="日报来源"
className="automation-leave-contact-picker"
>
<Input
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
placeholder="搜索群聊"
aria-label="搜索群聊"
/>
<div className="automation-leave-contact-list">
{visibleGroups.length === 0 ? (
<p className="automation-group-empty">没有可选的群聊</p>
) : (
visibleGroups.map((group) => (
<div key={group.id} className="automation-leave-radio option">
<RadioGroupItem value={group.name} id={`scheduled-source-${group.id}`} />
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
</div>
))
)}
</div>
</RadioGroup>
</div>
<div className="automation-field">
<span className="automation-field-label">日报范围</span>
<Select
value={draft.reportRange}
onValueChange={(value) =>
patch({ reportRange: value as ScheduledReportDraft['reportRange'] })
}
>
<SelectTrigger aria-label="日报范围">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULED_REPORT_RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 复用旧日报页的 MessageTypeSelector:类型 / 说明 /「至少选择一种」都来自真实定义。 */}
<MessageTypeSelector
value={draft.messageTypes}
disabled={saving}
onChange={(next) => patch({ messageTypes: next })}
/>
<div className="automation-field">
<span className="automation-field-label">日报内容</span>
<small>
与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、
活跃成员、活跃时间分布、关键词和内容密度。
</small>
</div>
<div className="automation-inline-row automation-model-row">
<div>
<span className="automation-field-label">模型配置</span>
<small>使用当前日报默认的文字总结和图片理解模型。</small>
</div>
{onOpenModelSettings ? (
<Button variant="link" size="sm" onClick={onOpenModelSettings}>
更改模型
</Button>
) : null}
</div>
<div className="automation-field">
<span className="automation-field-label">日报模板</span>
<Select
value={draft.templateId}
onValueChange={(value) =>
patch({ templateId: value as ScheduledReportDraft['templateId'] })
}
>
<SelectTrigger aria-label="日报模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="automation-field">
<span className="automation-field-label">成员名称</span>
<Select
value={draft.memberNameMode}
onValueChange={(value) =>
patch({ memberNameMode: value as ScheduledReportDraft['memberNameMode'] })
}
>
<SelectTrigger aria-label="成员名称">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULED_REPORT_MEMBER_NAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="automation-field">
<span className="automation-field-label">日报生成超时</span>
<div className="automation-scheduled-timeout">
<Input
aria-label="日报生成超时"
inputMode="numeric"
value={String(draft.timeoutSeconds)}
onChange={(event) => {
const next = Number(event.target.value.replace(/\D/g, '').slice(0, 4))
patch({ timeoutSeconds: Number.isNaN(next) ? draft.timeoutSeconds : next })
}}
/>
<span>秒</span>
</div>
</div>
</section>
{/* ---------- 3 · 发送目标四选一(不提供"指定其他群聊")---------- */}
<section className="automation-section">
<div className="automation-section-heading">
<h3>3 · 生成后发送到哪里</h3>
</div>
<p className="automation-section-lead">
生成哪个群的日报,与把日报发到哪里,是两件事。
</p>
<RadioGroup
value={draft.targetType}
onValueChange={(value) => selectTargetType(value as ScheduledReportTargetType)}
aria-label="生成后发送到哪里"
className="automation-leave-targets"
>
{SCHEDULED_REPORT_TARGET_OPTIONS.map((option) => (
<div key={option.type} className="automation-leave-radio option">
<RadioGroupItem value={option.type} id={`scheduled-target-${option.type}`} />
<div className="automation-leave-radio-body">
<label htmlFor={`scheduled-target-${option.type}`}>{option.label}</label>
<small>{option.description}</small>
</div>
</div>
))}
</RadioGroup>
{draft.targetType === 'contact' ? (
<div className="automation-field">
<span className="automation-field-label">指定好友</span>
<LeaveNotificationTargetPicker
contacts={sendableContacts}
selectedId={draft.targetContactId}
onSelect={(id) => patch({ targetContactId: id })}
ariaLabel="指定好友"
searchPlaceholder="搜索好友"
emptyText="没有可发送的联系人"
/>
</div>
) : null}
<p className="automation-editor-footnote">
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
TraceMemo 本身不额外保存或转发内容。
</p>
</section>
</div>
<ScheduledReportPreview
draft={draft}
groupLabel={resolveGroupDisplayName(draft.group, groups)}
contactName={selectedContactName}
/>
</div>
</div>
)
}
@@ -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<ScheduledReportTargetType, string> = {
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 (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实执行</span>
</div>
<ol className="automation-preview-flow">
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-time">{draft.scheduleTime}</span>
<span className="automation-preview-flow-title">定时任务触发</span>
<small>每天 {draft.scheduleTime}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">生成日报</span>
<small>来源:{groupText}</small>
<small>范围:{scheduledReportRangeLabel(draft.reportRange)}</small>
<small>纳入消息:{messageTypesSummary}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">模型</span>
<small>默认日报模型</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">模板</span>
<small>{scheduledReportTemplateLabel(draft.templateId)}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">发送到</span>
<small>{targetText}</small>
</li>
</ol>
{/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */}
<div className="automation-preview-report-card" aria-hidden="true">
<div className="automation-preview-report-head">
<span>{groupText}</span>
<span>{scheduledReportRangeLabel(draft.reportRange)}</span>
</div>
<div className="automation-preview-report-line wide" />
<div className="automation-preview-report-line" />
<div className="automation-preview-report-line" />
<div className="automation-preview-report-line short" />
</div>
<dl className="automation-preview-facts">
<div>
<dt>触发方式</dt>
<dd>每天 {draft.scheduleTime}</dd>
</div>
<div>
<dt>日报来源</dt>
<dd>{groupText}</dd>
</div>
<div>
<dt>日报范围</dt>
<dd>{scheduledReportRangeLabel(draft.reportRange)}</dd>
</div>
<div>
<dt>日报模板</dt>
<dd>{scheduledReportTemplateLabel(draft.templateId)}</dd>
</div>
<div>
<dt>发送目标</dt>
<dd>{targetText}</dd>
</div>
</dl>
<p className="automation-preview-footnote">
预计下次执行:{formatScheduledSlot(draft.scheduleTime)}
</p>
</section>
)
}
@@ -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 (
<div className="automation-rule-list">
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>定时日报</h2>
<span className="automation-section-note">
{tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'}
</span>
</div>
<p className="automation-section-lead">
按设定时间自动生成日报,并发送到指定微信会话。
</p>
<div className="automation-scheduled-toolbar">
<Button onClick={onCreate}>+ 新建定时日报</Button>
{tasks.length > 0 ? (
<span className="automation-section-note">
运行中 {runningCount} / {tasks.length}
</span>
) : null}
</div>
{loading ? (
<div className="automation-loading">
<Spinner />
<span>正在读取定时日报…</span>
</div>
) : tasks.length === 0 ? (
<div className="automation-empty-card">
<p>还没有定时日报</p>
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
<Button onClick={onCreate}>+ 新建定时日报</Button>
</div>
) : (
tasks.map((task) => (
<article
key={task.id}
className={`automation-rule-card ${task.enabled ? '' : 'disabled'}`}
>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{task.name}</h3>
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
<span className={`automation-rule-state ${task.enabled ? 'on' : 'off'}`}>
{task.enabled ? '运行中' : '已暂停'}
</span>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>每天 {task.scheduleTime}</dd>
</div>
<div>
<dt>日报</dt>
<dd>{scheduledReportRangeLabel(task.reportRange)}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{resolveGroupDisplay(task.group) || '未设置'}</dd>
</div>
<div>
<dt>模板</dt>
<dd>{scheduledReportTemplateLabel(task.templateId)}</dd>
</div>
<div>
<dt>发送到</dt>
{/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */}
<dd>
{!task.target || task.target === task.group
? '日报来源群'
: resolveGroupDisplay(task.target)}
</dd>
</div>
<div>
<dt>下次执行</dt>
<dd>{task.enabled ? formatNextRunAt(task.nextRunAt) : '—'}</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Switch
checked={task.enabled}
disabled={busyTaskId === task.id}
onCheckedChange={(checked) => onToggle(task, checked)}
aria-label={`${task.name} 启停`}
/>
<Button
variant="outline"
size="sm"
disabled={busyTaskId === task.id}
onClick={() => onRunNow(task)}
>
立即执行
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(task)}>
编辑
</Button>
<Button variant="link" size="sm" onClick={() => onDelete(task)}>
删除
</Button>
</div>
</article>
))
)}
</section>
</div>
)
}
@@ -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<ScheduledReportTask[]> {
const target = (globalThis as { api?: Record<string, unknown> }).api
const loader = target?.listScheduledReports
if (typeof loader !== 'function') return []
try {
const tasks = await (loader as () => Promise<unknown>).call(target)
return Array.isArray(tasks) ? (tasks as ScheduledReportTask[]) : []
} catch {
return []
}
}
function pad2(value: number): string {
return String(value).padStart(2, '0')
}
+192
View File
@@ -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;
}
@@ -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<typeof vi.fn>
updateScheduledReport: ReturnType<typeof vi.fn>
deleteScheduledReport: ReturnType<typeof vi.fn>
setScheduledReportEnabled: ReturnType<typeof vi.fn>
runScheduledReportNow: ReturnType<typeof vi.fn>
retryScheduledReportSend: ReturnType<typeof vi.fn>
testScheduledReportErrorNotification: ReturnType<typeof vi.fn>
listScheduledReports: ReturnType<typeof vi.fn>
}
/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */
function writeSpies(spies: Spies): Array<[string, ReturnType<typeof vi.fn>]> {
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(
<ToastProvider>
<AutomationWorkspace
dbReady
openRuleRequest={request ?? null}
onOpenModelSettings={() => {}}
/>
</ToastProvider>
)
}
/** 打开「规则 → 定时日报」。 */
async function openScheduledTab(): Promise<void> {
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<AutomationOpenRuleRequest>({
ruleType: 'scheduled_report',
requestId: 1
})
return <AutomationWorkspace dbReady openRuleRequest={request} />
}
render(
<ToastProvider>
<Harness />
</ToastProvider>
)
expect(await screen.findByText('2 条规则')).toBeVisible()
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
})
it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => {
installApi()
const onOpenAutomation = vi.fn()
render(
<ToastProvider>
<ScheduledReportsWorkspace
contacts={[]}
onOpenWechatSettings={() => {}}
onOpenAgentHub={() => {}}
onNotice={() => {}}
onOpenAutomation={onOpenAutomation}
/>
</ToastProvider>
)
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())
})
})
+49 -1
View File
@@ -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