mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 定时日报归到定制化功能下
This commit is contained in:
@@ -1677,6 +1677,16 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('automation')
|
setActivePage('automation')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「日报 → 定时日报 → 在自动化中配置」。
|
||||||
|
*
|
||||||
|
* 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。
|
||||||
|
*/
|
||||||
|
const openScheduledReportAutomation = (): void => {
|
||||||
|
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
|
||||||
|
setActivePage('automation')
|
||||||
|
}
|
||||||
|
|
||||||
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
|
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
|
||||||
const openExitMonitorGroups = (): void => {
|
const openExitMonitorGroups = (): void => {
|
||||||
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
|
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
|
||||||
@@ -2009,6 +2019,7 @@ function App(): React.ReactElement {
|
|||||||
onNotice={(message, variant) =>
|
onNotice={(message, variant) =>
|
||||||
toast({ description: message, variant, duration: 3200 })
|
toast({ description: message, variant, duration: 3200 })
|
||||||
}
|
}
|
||||||
|
onOpenAutomation={openScheduledReportAutomation}
|
||||||
/>
|
/>
|
||||||
) : reportWorkspaceView === 'result' ? (
|
) : reportWorkspaceView === 'result' ? (
|
||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
@@ -2136,6 +2147,7 @@ function App(): React.ReactElement {
|
|||||||
onOpenSendSettings={openWechatSendSettings}
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
onOpenExitMonitorGroups={openExitMonitorGroups}
|
onOpenExitMonitorGroups={openExitMonitorGroups}
|
||||||
openRuleRequest={automationOpenRuleRequest}
|
openRuleRequest={automationOpenRuleRequest}
|
||||||
|
onOpenModelSettings={openModelSettings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Checkbox } from '../ui'
|
|||||||
|
|
||||||
interface MessageTypeSelectorProps {
|
interface MessageTypeSelectorProps {
|
||||||
value: SummaryMessageType[]
|
value: SummaryMessageType[]
|
||||||
counts: Record<SummaryMessageType, number>
|
/**
|
||||||
|
* 每类消息的条数。
|
||||||
|
*
|
||||||
|
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
|
||||||
|
* 不传就不显示数字,而不是显示一排假的 0。
|
||||||
|
*/
|
||||||
|
counts?: Record<SummaryMessageType, number>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onChange: (value: SummaryMessageType[]) => void
|
onChange: (value: SummaryMessageType[]) => void
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
|
|||||||
<b>{option.label}</b>
|
<b>{option.label}</b>
|
||||||
<small>{option.description}</small>
|
<small>{option.description}</small>
|
||||||
</span>
|
</span>
|
||||||
<em>{counts[option.value]}</em>
|
{counts ? <em>{counts[option.value]}</em> : null}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ interface ScheduledReportsWorkspaceProps {
|
|||||||
onOpenAgentHub: () => void
|
onOpenAgentHub: () => void
|
||||||
onOpenModelSettings?: () => void
|
onOpenModelSettings?: () => void
|
||||||
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
|
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
|
||||||
|
/** 纯导航:跳到「自动化 → 规则 → 定时日报」;不改动本页任何数据。 */
|
||||||
|
onOpenAutomation?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeLabel = (range: ScheduledReportRange): string =>
|
const rangeLabel = (range: ScheduledReportRange): string =>
|
||||||
@@ -578,7 +580,8 @@ export function ScheduledReportsWorkspace({
|
|||||||
onOpenWechatSettings,
|
onOpenWechatSettings,
|
||||||
onOpenAgentHub,
|
onOpenAgentHub,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
onNotice
|
onNotice,
|
||||||
|
onOpenAutomation
|
||||||
}: ScheduledReportsWorkspaceProps): React.ReactElement {
|
}: ScheduledReportsWorkspaceProps): React.ReactElement {
|
||||||
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
|
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
|
||||||
const [capabilityError, setCapabilityError] = React.useState(false)
|
const [capabilityError, setCapabilityError] = React.useState(false)
|
||||||
@@ -862,6 +865,15 @@ export function ScheduledReportsWorkspace({
|
|||||||
<div>
|
<div>
|
||||||
<h1>定时日报</h1>
|
<h1>定时日报</h1>
|
||||||
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
|
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
|
||||||
|
{onOpenAutomation ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-scheduled-automation-entry"
|
||||||
|
onClick={onOpenAutomation}
|
||||||
|
>
|
||||||
|
在自动化中配置 →
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,6 +2,30 @@ import * as React from 'react'
|
|||||||
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
|
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
|
||||||
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
|
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 —— 编辑器上方的**规则类型切换**。
|
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
||||||
*
|
*
|
||||||
@@ -12,26 +36,24 @@ import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../.
|
|||||||
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
|
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'leave_notification']
|
|
||||||
|
|
||||||
export function AutomationRuleTypeTabs({
|
export function AutomationRuleTypeTabs({
|
||||||
value,
|
value,
|
||||||
onValueChange
|
onValueChange
|
||||||
}: {
|
}: {
|
||||||
value: AutomationRuleType
|
value: AutomationRuleTabType
|
||||||
onValueChange: (next: AutomationRuleType) => void
|
onValueChange: (next: AutomationRuleTabType) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="automation-rule-type-tabs">
|
<div className="automation-rule-type-tabs">
|
||||||
<span className="automation-rule-type-label">切换规则类型</span>
|
<span className="automation-rule-type-label">切换规则类型</span>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(next) => onValueChange(next as AutomationRuleType)}
|
onValueChange={(next) => onValueChange(next as AutomationRuleTabType)}
|
||||||
aria-label="切换规则类型"
|
aria-label="切换规则类型"
|
||||||
>
|
>
|
||||||
{RULE_TYPES.map((type) => (
|
{RULE_TABS.map((type) => (
|
||||||
<SegmentedControlItem key={type} value={type}>
|
<SegmentedControlItem key={type} value={type}>
|
||||||
{AUTOMATION_RULE_TYPE_LABELS[type]}
|
{TAB_LABELS[type]}
|
||||||
</SegmentedControlItem>
|
</SegmentedControlItem>
|
||||||
))}
|
))}
|
||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AutomationExecution,
|
type AutomationExecution,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type AutomationRuleDraft,
|
type AutomationRuleDraft,
|
||||||
type AutomationRuleType,
|
|
||||||
type AutomationStatusSummary
|
type AutomationStatusSummary
|
||||||
} from '../../../../shared/automation'
|
} from '../../../../shared/automation'
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +18,21 @@ import { RuleListPanel } from './RuleListPanel'
|
|||||||
import { RuleEditorPanel } from './RuleEditorPanel'
|
import { RuleEditorPanel } from './RuleEditorPanel'
|
||||||
import { ExecutionLogPanel } from './ExecutionLogPanel'
|
import { ExecutionLogPanel } from './ExecutionLogPanel'
|
||||||
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
|
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 {
|
import {
|
||||||
LeaveNotificationEditor,
|
LeaveNotificationEditor,
|
||||||
type LeaveNotificationSaveInput
|
type LeaveNotificationSaveInput
|
||||||
@@ -42,7 +55,7 @@ type AutomationTab = 'rules' | 'logs'
|
|||||||
|
|
||||||
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
|
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
|
||||||
export interface AutomationOpenRuleRequest {
|
export interface AutomationOpenRuleRequest {
|
||||||
ruleType: AutomationRuleType
|
ruleType: AutomationRuleTabType
|
||||||
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
|
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
|
||||||
requestId: number
|
requestId: number
|
||||||
}
|
}
|
||||||
@@ -53,6 +66,8 @@ export interface AutomationWorkspaceProps {
|
|||||||
onOpenSendSettings?: () => void
|
onOpenSendSettings?: () => void
|
||||||
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
|
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
|
||||||
onOpenExitMonitorGroups?: () => void
|
onOpenExitMonitorGroups?: () => void
|
||||||
|
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
|
||||||
|
onOpenModelSettings?: () => void
|
||||||
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
||||||
openRuleRequest?: AutomationOpenRuleRequest | null
|
openRuleRequest?: AutomationOpenRuleRequest | null
|
||||||
}
|
}
|
||||||
@@ -60,13 +75,15 @@ export interface AutomationWorkspaceProps {
|
|||||||
interface EditorState {
|
interface EditorState {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
rule: AutomationRule | null
|
rule: AutomationRule | null
|
||||||
ruleType: AutomationRuleType
|
/** 用 Tab 类型:定时日报只在本层存在,不进真实 `AutomationRule`。 */
|
||||||
|
ruleType: AutomationRuleTabType
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationWorkspace({
|
export function AutomationWorkspace({
|
||||||
dbReady,
|
dbReady,
|
||||||
onOpenSendSettings,
|
onOpenSendSettings,
|
||||||
onOpenExitMonitorGroups,
|
onOpenExitMonitorGroups,
|
||||||
|
onOpenModelSettings,
|
||||||
openRuleRequest
|
openRuleRequest
|
||||||
}: AutomationWorkspaceProps): React.ReactElement {
|
}: AutomationWorkspaceProps): React.ReactElement {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -152,12 +169,6 @@ export function AutomationWorkspace({
|
|||||||
const leaveNotificationRule =
|
const leaveNotificationRule =
|
||||||
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
|
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 => {
|
const leaveContactName = React.useMemo((): string => {
|
||||||
@@ -183,6 +194,102 @@ export function AutomationWorkspace({
|
|||||||
)
|
)
|
||||||
}, [rules])
|
}, [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> => {
|
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||||
setBusyRuleId(rule.id)
|
setBusyRuleId(rule.id)
|
||||||
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
||||||
@@ -397,6 +504,13 @@ export function AutomationWorkspace({
|
|||||||
value={editor.ruleType}
|
value={editor.ruleType}
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
if (next === editor.ruleType) return
|
if (next === editor.ruleType) return
|
||||||
|
// 离开定时日报时清掉它的编辑态,下次进来回到列表。
|
||||||
|
closeScheduledEditor()
|
||||||
|
if (next === 'scheduled_report') {
|
||||||
|
// 定时日报是**多条**规则:先给列表,不直接进编辑器。
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (next === 'leave_notification') {
|
if (next === 'leave_notification') {
|
||||||
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
|
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
|
||||||
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||||
@@ -418,9 +532,11 @@ export function AutomationWorkspace({
|
|||||||
编辑目标必须**可见**。
|
编辑目标必须**可见**。
|
||||||
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
|
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
|
||||||
*/}
|
*/}
|
||||||
|
{editor.ruleType !== 'scheduled_report' || scheduledEditor ? (
|
||||||
<p className="automation-editing-target" data-testid="automation-editing-target">
|
<p className="automation-editing-target" data-testid="automation-editing-target">
|
||||||
正在编辑:<strong>{editorTargetName}</strong>
|
正在编辑:<strong>{editorTargetName}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{editor.ruleType === 'daily_report' ? (
|
{editor.ruleType === 'daily_report' ? (
|
||||||
/*
|
/*
|
||||||
@@ -444,6 +560,33 @@ export function AutomationWorkspace({
|
|||||||
onSave={(draft) => void handleSave(draft)}
|
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 ? (
|
) : leaveNotificationRule ? (
|
||||||
<LeaveNotificationEditor
|
<LeaveNotificationEditor
|
||||||
rule={leaveNotificationRule}
|
rule={leaveNotificationRule}
|
||||||
@@ -517,6 +660,10 @@ export function AutomationWorkspace({
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
scheduledReport={scheduledSummary}
|
||||||
|
onManageScheduledReport={() =>
|
||||||
|
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||||
|
}
|
||||||
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
|
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
|
||||||
onEdit={(rule) => {
|
onEdit={(rule) => {
|
||||||
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
|
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
|
||||||
|
|||||||
@@ -29,18 +29,32 @@ export interface LeaveNotificationCardModel {
|
|||||||
contactName: string
|
contactName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报在首页的**汇总卡**数据。
|
||||||
|
*
|
||||||
|
* 它是多条规则,不能像 singleton 那样当成一条展示(也不能给它一个启停开关)。
|
||||||
|
*/
|
||||||
|
export interface ScheduledReportCardModel {
|
||||||
|
total: number
|
||||||
|
running: number
|
||||||
|
nextRunLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleListPanelProps {
|
export interface RuleListPanelProps {
|
||||||
rules: AutomationRule[]
|
rules: AutomationRule[]
|
||||||
groups: Array<{ id: string; name: string }>
|
groups: Array<{ id: string; name: string }>
|
||||||
loading: boolean
|
loading: boolean
|
||||||
busyRuleId: string | null
|
busyRuleId: string | null
|
||||||
leaveNotification: LeaveNotificationCardModel | null
|
leaveNotification: LeaveNotificationCardModel | null
|
||||||
|
scheduledReport: ScheduledReportCardModel | null
|
||||||
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||||
onEdit: (rule: AutomationRule) => void
|
onEdit: (rule: AutomationRule) => void
|
||||||
onDelete: (rule: AutomationRule) => void
|
onDelete: (rule: AutomationRule) => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
/** 打开「退群通知」规则类型。 */
|
/** 打开「退群通知」规则类型。 */
|
||||||
onEditLeaveNotification: () => void
|
onEditLeaveNotification: () => void
|
||||||
|
/** 打开「定时日报」规则类型(多条,所以是"管理"而不是"编辑")。 */
|
||||||
|
onManageScheduledReport: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
||||||
@@ -72,11 +86,13 @@ export function RuleListPanel({
|
|||||||
loading,
|
loading,
|
||||||
busyRuleId,
|
busyRuleId,
|
||||||
leaveNotification,
|
leaveNotification,
|
||||||
|
scheduledReport,
|
||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
onEditLeaveNotification
|
onEditLeaveNotification,
|
||||||
|
onManageScheduledReport
|
||||||
}: RuleListPanelProps): React.ReactElement {
|
}: RuleListPanelProps): React.ReactElement {
|
||||||
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
||||||
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||||
@@ -207,6 +223,36 @@ export function RuleListPanel({
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : 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>
|
</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')
|
||||||
|
}
|
||||||
@@ -1483,3 +1483,195 @@
|
|||||||
font-weight: 600;
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -469,7 +469,55 @@ handle('automation:saveLeaveNotificationRule', (draft) => {
|
|||||||
return structuredClone(leaveNotificationRule)
|
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 scheduledReportExecutions = []
|
||||||
const generatedReports = []
|
const generatedReports = []
|
||||||
let scheduledReportNotificationEnabled = false
|
let scheduledReportNotificationEnabled = false
|
||||||
|
|||||||
Reference in New Issue
Block a user