mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
feat: 定时日报归到定制化功能下
This commit is contained in:
@@ -1677,6 +1677,16 @@ function App(): React.ReactElement {
|
||||
setActivePage('automation')
|
||||
}
|
||||
|
||||
/**
|
||||
* 「日报 → 定时日报 → 在自动化中配置」。
|
||||
*
|
||||
* 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。
|
||||
*/
|
||||
const openScheduledReportAutomation = (): void => {
|
||||
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
|
||||
setActivePage('automation')
|
||||
}
|
||||
|
||||
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
|
||||
const openExitMonitorGroups = (): void => {
|
||||
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
|
||||
@@ -2009,6 +2019,7 @@ function App(): React.ReactElement {
|
||||
onNotice={(message, variant) =>
|
||||
toast({ description: message, variant, duration: 3200 })
|
||||
}
|
||||
onOpenAutomation={openScheduledReportAutomation}
|
||||
/>
|
||||
) : reportWorkspaceView === 'result' ? (
|
||||
<div className="report-center-page">
|
||||
@@ -2136,6 +2147,7 @@ function App(): React.ReactElement {
|
||||
onOpenSendSettings={openWechatSendSettings}
|
||||
onOpenExitMonitorGroups={openExitMonitorGroups}
|
||||
openRuleRequest={automationOpenRuleRequest}
|
||||
onOpenModelSettings={openModelSettings}
|
||||
/>
|
||||
)
|
||||
case 'agent-hub':
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Checkbox } from '../ui'
|
||||
|
||||
interface MessageTypeSelectorProps {
|
||||
value: SummaryMessageType[]
|
||||
counts: Record<SummaryMessageType, number>
|
||||
/**
|
||||
* 每类消息的条数。
|
||||
*
|
||||
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
|
||||
* 不传就不显示数字,而不是显示一排假的 0。
|
||||
*/
|
||||
counts?: Record<SummaryMessageType, number>
|
||||
disabled: boolean
|
||||
onChange: (value: SummaryMessageType[]) => void
|
||||
}
|
||||
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
|
||||
<b>{option.label}</b>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
<em>{counts[option.value]}</em>
|
||||
{counts ? <em>{counts[option.value]}</em> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,8 @@ interface ScheduledReportsWorkspaceProps {
|
||||
onOpenAgentHub: () => void
|
||||
onOpenModelSettings?: () => void
|
||||
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
|
||||
/** 纯导航:跳到「自动化 → 规则 → 定时日报」;不改动本页任何数据。 */
|
||||
onOpenAutomation?: () => void
|
||||
}
|
||||
|
||||
const rangeLabel = (range: ScheduledReportRange): string =>
|
||||
@@ -578,7 +580,8 @@ export function ScheduledReportsWorkspace({
|
||||
onOpenWechatSettings,
|
||||
onOpenAgentHub,
|
||||
onOpenModelSettings,
|
||||
onNotice
|
||||
onNotice,
|
||||
onOpenAutomation
|
||||
}: ScheduledReportsWorkspaceProps): React.ReactElement {
|
||||
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
|
||||
const [capabilityError, setCapabilityError] = React.useState(false)
|
||||
@@ -862,6 +865,15 @@ export function ScheduledReportsWorkspace({
|
||||
<div>
|
||||
<h1>定时日报</h1>
|
||||
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
|
||||
{onOpenAutomation ? (
|
||||
<button
|
||||
type="button"
|
||||
className="report-scheduled-automation-entry"
|
||||
onClick={onOpenAutomation}
|
||||
>
|
||||
在自动化中配置 →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,6 +2,30 @@ import * as React from 'react'
|
||||
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
|
||||
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
|
||||
|
||||
/**
|
||||
* Tab 上能出现的规则类型。
|
||||
*
|
||||
* `scheduled_report` **只存在于这一层**:真实 `AutomationRule.ruleType`
|
||||
* 仍然只有 `daily_report` / `leave_notification`
|
||||
* —— 不往真实 schema 里塞一个还不存在的类型。
|
||||
*/
|
||||
export type AutomationRuleTabType = AutomationRuleType | 'scheduled_report'
|
||||
|
||||
/**
|
||||
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
|
||||
*/
|
||||
const RULE_TABS: AutomationRuleTabType[] = [
|
||||
'daily_report',
|
||||
'scheduled_report',
|
||||
'leave_notification'
|
||||
]
|
||||
|
||||
const TAB_LABELS: Record<AutomationRuleTabType, string> = {
|
||||
daily_report: AUTOMATION_RULE_TYPE_LABELS.daily_report,
|
||||
scheduled_report: '定时日报',
|
||||
leave_notification: AUTOMATION_RULE_TYPE_LABELS.leave_notification
|
||||
}
|
||||
|
||||
/**
|
||||
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
||||
*
|
||||
@@ -12,26 +36,24 @@ import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../.
|
||||
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
|
||||
*/
|
||||
|
||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'leave_notification']
|
||||
|
||||
export function AutomationRuleTypeTabs({
|
||||
value,
|
||||
onValueChange
|
||||
}: {
|
||||
value: AutomationRuleType
|
||||
onValueChange: (next: AutomationRuleType) => void
|
||||
value: AutomationRuleTabType
|
||||
onValueChange: (next: AutomationRuleTabType) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="automation-rule-type-tabs">
|
||||
<span className="automation-rule-type-label">切换规则类型</span>
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
onValueChange={(next) => onValueChange(next as AutomationRuleType)}
|
||||
onValueChange={(next) => onValueChange(next as AutomationRuleTabType)}
|
||||
aria-label="切换规则类型"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
{RULE_TABS.map((type) => (
|
||||
<SegmentedControlItem key={type} value={type}>
|
||||
{AUTOMATION_RULE_TYPE_LABELS[type]}
|
||||
{TAB_LABELS[type]}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type AutomationRuleDraft,
|
||||
type AutomationRuleType,
|
||||
type AutomationStatusSummary
|
||||
} from '../../../../shared/automation'
|
||||
import {
|
||||
@@ -19,7 +18,21 @@ import { RuleListPanel } from './RuleListPanel'
|
||||
import { RuleEditorPanel } from './RuleEditorPanel'
|
||||
import { ExecutionLogPanel } from './ExecutionLogPanel'
|
||||
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
|
||||
import { AutomationRuleTypeTabs } from './AutomationRuleTypeTabs'
|
||||
import {
|
||||
AutomationRuleTypeTabs,
|
||||
type AutomationRuleTabType
|
||||
} from './AutomationRuleTypeTabs'
|
||||
import { ScheduledReportEditor } from './ScheduledReportEditor'
|
||||
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
|
||||
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
|
||||
import {
|
||||
createEmptyScheduledReportDraft,
|
||||
createDraftFromTask,
|
||||
formatNextRunAt,
|
||||
loadScheduledReportTasks,
|
||||
resolveGroupDisplayName,
|
||||
type ScheduledReportDraft
|
||||
} from './model/scheduled-report-preview'
|
||||
import {
|
||||
LeaveNotificationEditor,
|
||||
type LeaveNotificationSaveInput
|
||||
@@ -42,7 +55,7 @@ type AutomationTab = 'rules' | 'logs'
|
||||
|
||||
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
|
||||
export interface AutomationOpenRuleRequest {
|
||||
ruleType: AutomationRuleType
|
||||
ruleType: AutomationRuleTabType
|
||||
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
|
||||
requestId: number
|
||||
}
|
||||
@@ -53,6 +66,8 @@ export interface AutomationWorkspaceProps {
|
||||
onOpenSendSettings?: () => void
|
||||
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
|
||||
onOpenExitMonitorGroups?: () => void
|
||||
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
|
||||
onOpenModelSettings?: () => void
|
||||
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
||||
openRuleRequest?: AutomationOpenRuleRequest | null
|
||||
}
|
||||
@@ -60,13 +75,15 @@ export interface AutomationWorkspaceProps {
|
||||
interface EditorState {
|
||||
mode: 'create' | 'edit'
|
||||
rule: AutomationRule | null
|
||||
ruleType: AutomationRuleType
|
||||
/** 用 Tab 类型:定时日报只在本层存在,不进真实 `AutomationRule`。 */
|
||||
ruleType: AutomationRuleTabType
|
||||
}
|
||||
|
||||
export function AutomationWorkspace({
|
||||
dbReady,
|
||||
onOpenSendSettings,
|
||||
onOpenExitMonitorGroups,
|
||||
onOpenModelSettings,
|
||||
openRuleRequest
|
||||
}: AutomationWorkspaceProps): React.ReactElement {
|
||||
const { toast } = useToast()
|
||||
@@ -152,12 +169,6 @@ export function AutomationWorkspace({
|
||||
const leaveNotificationRule =
|
||||
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
|
||||
|
||||
/** 「正在编辑」显示的名字(与保存目标同源)。 */
|
||||
const editorTargetName = React.useMemo((): string => {
|
||||
if (!editor) return ''
|
||||
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
|
||||
return dailyReportTarget?.name ?? '新建自动化'
|
||||
}, [editor, leaveNotificationRule, dailyReportTarget])
|
||||
|
||||
/** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */
|
||||
const leaveContactName = React.useMemo((): string => {
|
||||
@@ -183,6 +194,102 @@ export function AutomationWorkspace({
|
||||
)
|
||||
}, [rules])
|
||||
|
||||
/*
|
||||
* ---------- 定时日报(只读)----------
|
||||
*
|
||||
* 数据是**只读**拿到的真实任务;所有写操作(保存 / 立即执行 / 启停 / 删除)
|
||||
* 一律不落盘、不调 scheduler、不生成日报。详见 `model/scheduled-report-preview.ts`。
|
||||
*/
|
||||
const [scheduledTasks, setScheduledTasks] = React.useState<ScheduledReportTask[]>([])
|
||||
const [scheduledLoading, setScheduledLoading] = React.useState(true)
|
||||
/** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */
|
||||
const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>(
|
||||
null
|
||||
)
|
||||
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
|
||||
|
||||
/** 「正在编辑」显示的名字(与保存目标同源)。 */
|
||||
const editorTargetName = React.useMemo((): string => {
|
||||
if (!editor) return ''
|
||||
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
|
||||
if (editor.ruleType === 'scheduled_report') {
|
||||
return scheduledDraft?.name?.trim() || '新建定时日报'
|
||||
}
|
||||
return dailyReportTarget?.name ?? '新建自动化'
|
||||
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
|
||||
|
||||
React.useEffect(() => {
|
||||
let disposed = false
|
||||
setScheduledLoading(true)
|
||||
void loadScheduledReportTasks()
|
||||
.then((tasks) => {
|
||||
if (!disposed) setScheduledTasks(tasks)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setScheduledLoading(false)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 首页汇总卡:数量 + 最近一次执行时间(加载中先不显示,避免闪一个"0 条")。 */
|
||||
const scheduledSummary = React.useMemo(() => {
|
||||
if (scheduledLoading) return null
|
||||
const running = scheduledTasks.filter((task) => task.enabled)
|
||||
const nextRunAt = running
|
||||
.map((task) => task.nextRunAt)
|
||||
.filter((value) => Boolean(value))
|
||||
.sort()[0]
|
||||
return {
|
||||
total: scheduledTasks.length,
|
||||
running: running.length,
|
||||
nextRunLabel: nextRunAt ? formatNextRunAt(nextRunAt) : '—'
|
||||
}
|
||||
}, [scheduledTasks, scheduledLoading])
|
||||
|
||||
/** 群标识 → 显示名(列表与预览共用,保证任何地方都不出现裸 id)。 */
|
||||
const resolveScheduledGroupDisplay = React.useCallback(
|
||||
(raw: string): string => resolveGroupDisplayName(raw, groups),
|
||||
[groups]
|
||||
)
|
||||
|
||||
const PREVIEW_NOTICE = 'UI 预览模式,配置暂未保存'
|
||||
|
||||
const openScheduledEditor = (task: ScheduledReportTask | null): void => {
|
||||
setScheduledDraft(task ? createDraftFromTask(task) : createEmptyScheduledReportDraft())
|
||||
setScheduledEditor({ taskId: task?.id ?? null })
|
||||
}
|
||||
|
||||
const closeScheduledEditor = (): void => {
|
||||
setScheduledEditor(null)
|
||||
setScheduledDraft(null)
|
||||
}
|
||||
|
||||
/** 保存:只提示,绝不 create/update。 */
|
||||
const handleScheduledSave = (): void => {
|
||||
toast({ description: PREVIEW_NOTICE, duration: 3200 })
|
||||
closeScheduledEditor()
|
||||
}
|
||||
|
||||
/** 立即执行:不碰 scheduler / 生成 / 发送。 */
|
||||
const handleScheduledRunNow = (_task: ScheduledReportTask): void => {
|
||||
toast({ description: 'UI 预览模式,不会执行真实日报任务', duration: 3200 })
|
||||
}
|
||||
|
||||
/** 启停:只改本地列表,不写 store。 */
|
||||
const handleScheduledToggle = (task: ScheduledReportTask, enabled: boolean): void => {
|
||||
setScheduledTasks((current) =>
|
||||
current.map((item) => (item.id === task.id ? { ...item, enabled } : item))
|
||||
)
|
||||
toast({ description: PREVIEW_NOTICE, duration: 3200 })
|
||||
}
|
||||
|
||||
/** 删除:直接提示,避免用户以为任务真被删了。 */
|
||||
const handleScheduledDelete = (_task: ScheduledReportTask): void => {
|
||||
toast({ description: 'UI 预览模式,不会删除现有任务', duration: 3200 })
|
||||
}
|
||||
|
||||
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||
setBusyRuleId(rule.id)
|
||||
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
||||
@@ -397,6 +504,13 @@ export function AutomationWorkspace({
|
||||
value={editor.ruleType}
|
||||
onValueChange={(next) => {
|
||||
if (next === editor.ruleType) return
|
||||
// 离开定时日报时清掉它的编辑态,下次进来回到列表。
|
||||
closeScheduledEditor()
|
||||
if (next === 'scheduled_report') {
|
||||
// 定时日报是**多条**规则:先给列表,不直接进编辑器。
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||
return
|
||||
}
|
||||
if (next === 'leave_notification') {
|
||||
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||
@@ -418,9 +532,11 @@ export function AutomationWorkspace({
|
||||
编辑目标必须**可见**。
|
||||
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
|
||||
*/}
|
||||
<p className="automation-editing-target" data-testid="automation-editing-target">
|
||||
正在编辑:<strong>{editorTargetName}</strong>
|
||||
</p>
|
||||
{editor.ruleType !== 'scheduled_report' || scheduledEditor ? (
|
||||
<p className="automation-editing-target" data-testid="automation-editing-target">
|
||||
正在编辑:<strong>{editorTargetName}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editor.ruleType === 'daily_report' ? (
|
||||
/*
|
||||
@@ -444,6 +560,33 @@ export function AutomationWorkspace({
|
||||
onSave={(draft) => void handleSave(draft)}
|
||||
/>
|
||||
)
|
||||
) : editor.ruleType === 'scheduled_report' ? (
|
||||
scheduledEditor && scheduledDraft ? (
|
||||
<ScheduledReportEditor
|
||||
// 换一条任务就重建编辑器,避免把上一条的草稿带过来。
|
||||
key={scheduledEditor.taskId ?? 'new'}
|
||||
initialDraft={scheduledDraft}
|
||||
groups={groups}
|
||||
sendableContacts={sendableContacts}
|
||||
saving={saving}
|
||||
onBack={closeScheduledEditor}
|
||||
onCancel={closeScheduledEditor}
|
||||
onSave={handleScheduledSave}
|
||||
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
|
||||
/>
|
||||
) : (
|
||||
<ScheduledReportRuleList
|
||||
tasks={scheduledTasks}
|
||||
loading={scheduledLoading}
|
||||
busyTaskId={null}
|
||||
resolveGroupDisplay={resolveScheduledGroupDisplay}
|
||||
onCreate={() => openScheduledEditor(null)}
|
||||
onEdit={openScheduledEditor}
|
||||
onRunNow={handleScheduledRunNow}
|
||||
onToggle={handleScheduledToggle}
|
||||
onDelete={handleScheduledDelete}
|
||||
/>
|
||||
)
|
||||
) : leaveNotificationRule ? (
|
||||
<LeaveNotificationEditor
|
||||
rule={leaveNotificationRule}
|
||||
@@ -517,6 +660,10 @@ export function AutomationWorkspace({
|
||||
}
|
||||
: null
|
||||
}
|
||||
scheduledReport={scheduledSummary}
|
||||
onManageScheduledReport={() =>
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
|
||||
}
|
||||
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
|
||||
onEdit={(rule) => {
|
||||
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
|
||||
|
||||
@@ -29,18 +29,32 @@ export interface LeaveNotificationCardModel {
|
||||
contactName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时日报在首页的**汇总卡**数据。
|
||||
*
|
||||
* 它是多条规则,不能像 singleton 那样当成一条展示(也不能给它一个启停开关)。
|
||||
*/
|
||||
export interface ScheduledReportCardModel {
|
||||
total: number
|
||||
running: number
|
||||
nextRunLabel: string
|
||||
}
|
||||
|
||||
export interface RuleListPanelProps {
|
||||
rules: AutomationRule[]
|
||||
groups: Array<{ id: string; name: string }>
|
||||
loading: boolean
|
||||
busyRuleId: string | null
|
||||
leaveNotification: LeaveNotificationCardModel | null
|
||||
scheduledReport: ScheduledReportCardModel | null
|
||||
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||
onEdit: (rule: AutomationRule) => void
|
||||
onDelete: (rule: AutomationRule) => void
|
||||
onCreate: () => void
|
||||
/** 打开「退群通知」规则类型。 */
|
||||
onEditLeaveNotification: () => void
|
||||
/** 打开「定时日报」规则类型(多条,所以是"管理"而不是"编辑")。 */
|
||||
onManageScheduledReport: () => void
|
||||
}
|
||||
|
||||
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
||||
@@ -72,11 +86,13 @@ export function RuleListPanel({
|
||||
loading,
|
||||
busyRuleId,
|
||||
leaveNotification,
|
||||
scheduledReport,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
onEditLeaveNotification
|
||||
onEditLeaveNotification,
|
||||
onManageScheduledReport
|
||||
}: RuleListPanelProps): React.ReactElement {
|
||||
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
||||
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
@@ -207,6 +223,36 @@ export function RuleListPanel({
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
{scheduledReport ? (
|
||||
<article className="automation-rule-card">
|
||||
<div className="automation-rule-card-main">
|
||||
<div className="automation-rule-card-title">
|
||||
<h3>定时日报</h3>
|
||||
{/* 汇总卡:它代表多条规则,所以只报数量,不给单个启停开关。 */}
|
||||
<span
|
||||
className={`automation-rule-state ${scheduledReport.running > 0 ? 'on' : 'off'}`}
|
||||
>
|
||||
{scheduledReport.total} 条规则 · {scheduledReport.running} 条运行中
|
||||
</span>
|
||||
</div>
|
||||
<dl className="automation-rule-meta">
|
||||
<div>
|
||||
<dt>触发</dt>
|
||||
<dd>按设定时间</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>下次执行</dt>
|
||||
<dd>{scheduledReport.nextRunLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="automation-rule-card-side">
|
||||
<Button variant="outline" size="sm" onClick={onManageScheduledReport}>
|
||||
管理定时日报 →
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch
|
||||
} from '../../components/ui'
|
||||
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
|
||||
import {
|
||||
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
||||
SCHEDULED_REPORT_RANGE_OPTIONS,
|
||||
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
|
||||
composeScheduleTime,
|
||||
formatScheduledSlot,
|
||||
parseScheduleTime,
|
||||
resolveGroupDisplayName,
|
||||
type ScheduledReportDraft,
|
||||
type ScheduledReportTargetType
|
||||
} from './model/scheduled-report-preview'
|
||||
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
|
||||
import { ScheduledReportPreview } from './ScheduledReportPreview'
|
||||
|
||||
/**
|
||||
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
|
||||
*
|
||||
* 三段:什么时候触发 / 生成什么日报 / 生成后发送到哪里。
|
||||
*
|
||||
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
|
||||
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
|
||||
*
|
||||
* 草稿只存在 renderer local state,保存不落盘(由父层提示)。
|
||||
*/
|
||||
|
||||
export interface ScheduledReportEditorProps {
|
||||
initialDraft: ScheduledReportDraft
|
||||
/** 可选群聊(自动化既有的群列表,与其它规则类型同一来源)。 */
|
||||
groups: Array<{ id: string; name: string }>
|
||||
/** 「指定好友」的候选项(与退群通知共用同一个来源与组件)。 */
|
||||
sendableContacts: Array<{ id: string; name: string }>
|
||||
saving: boolean
|
||||
/** 返回「定时日报」规则列表(**不是**退回自动化首页)。 */
|
||||
onBack: () => void
|
||||
onCancel: () => void
|
||||
onSave: (draft: ScheduledReportDraft) => void
|
||||
/** 复用现有的模型配置入口。 */
|
||||
onOpenModelSettings?: () => void
|
||||
}
|
||||
|
||||
export function ScheduledReportEditor({
|
||||
initialDraft,
|
||||
groups,
|
||||
sendableContacts,
|
||||
saving,
|
||||
onBack,
|
||||
onCancel,
|
||||
onSave,
|
||||
onOpenModelSettings
|
||||
}: ScheduledReportEditorProps): React.ReactElement {
|
||||
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
|
||||
const [groupFilter, setGroupFilter] = React.useState('')
|
||||
|
||||
const patch = (next: Partial<ScheduledReportDraft>): void =>
|
||||
setDraft((current) => ({ ...current, ...next }))
|
||||
|
||||
/*
|
||||
* 时间的两个输入框保留**本地文本态**。
|
||||
* 受控回写会在输入过程中把中间态规范化掉,用户就永远拼不出想要的数字;
|
||||
* 规范化推迟到失焦。
|
||||
*/
|
||||
const [hourText, setHourText] = React.useState(() =>
|
||||
String(parseScheduleTime(initialDraft.scheduleTime)[0]).padStart(2, '0')
|
||||
)
|
||||
const [minuteText, setMinuteText] = React.useState(() =>
|
||||
String(parseScheduleTime(initialDraft.scheduleTime)[1]).padStart(2, '0')
|
||||
)
|
||||
|
||||
const commitHour = (raw: string): void => {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||
setHourText(digits)
|
||||
if (digits === '') return
|
||||
const value = Number(digits)
|
||||
if (Number.isNaN(value)) return
|
||||
patch({ scheduleTime: composeScheduleTime(value, parseScheduleTime(draft.scheduleTime)[1]) })
|
||||
}
|
||||
|
||||
const commitMinute = (raw: string): void => {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||
setMinuteText(digits)
|
||||
if (digits === '') return
|
||||
const value = Number(digits)
|
||||
if (Number.isNaN(value)) return
|
||||
patch({ scheduleTime: composeScheduleTime(parseScheduleTime(draft.scheduleTime)[0], value) })
|
||||
}
|
||||
|
||||
/*
|
||||
* 候选群 = 自动化现有的群列表 **并上** 当前任务已在用的来源群。
|
||||
*
|
||||
* 任务里存的群标识与群列表是两个数据源,不保证一致;不并的话,
|
||||
* 编辑已有任务会出现"当前来源群在列表里找不到、于是没有任何一项被选中"。
|
||||
*/
|
||||
const groupOptions = React.useMemo(() => {
|
||||
const known = new Set(groups.map((group) => group.name))
|
||||
// 去重:不去重会生成两条同 id 的候选项,React 重复 key 会让渲染错乱。
|
||||
const extras = Array.from(
|
||||
new Set(
|
||||
[initialDraft.group, initialDraft.target].filter(
|
||||
(value) => Boolean(value) && !known.has(value)
|
||||
)
|
||||
)
|
||||
).map((value) => ({ id: `current:${value}`, name: value }))
|
||||
return [...groups, ...extras]
|
||||
}, [groups, initialDraft.group, initialDraft.target])
|
||||
|
||||
const visibleGroups = React.useMemo(() => {
|
||||
const needle = groupFilter.trim().toLowerCase()
|
||||
if (!needle) return groupOptions
|
||||
return groupOptions.filter(
|
||||
(group) =>
|
||||
group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle)
|
||||
)
|
||||
}, [groupOptions, groupFilter])
|
||||
|
||||
const selectedContactName =
|
||||
sendableContacts.find((contact) => contact.id === draft.targetContactId)?.name ?? ''
|
||||
|
||||
const selectTargetType = (next: ScheduledReportTargetType): void => {
|
||||
// 「发送到日报来源群」的目标跟随来源群;其余目标清空,避免看起来已经选好了。
|
||||
patch({
|
||||
targetType: next,
|
||||
target: next === 'source_chat' ? draft.group : '',
|
||||
targetContactId: next === 'contact' ? draft.targetContactId : ''
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="automation-editor">
|
||||
<button type="button" className="automation-editor-back" onClick={onBack}>
|
||||
← 返回定时日报
|
||||
</button>
|
||||
|
||||
<header className="automation-editor-header">
|
||||
<div>
|
||||
<h2>{draft.name.trim() || '新建定时日报'}</h2>
|
||||
<p>按设定时间自动生成日报,并发送到指定微信会话。</p>
|
||||
<div className="automation-leave-status-row">
|
||||
<span className="automation-field-label">启用这条自动化</span>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => patch({ enabled: checked })}
|
||||
aria-label="启用这条自动化"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="automation-editor-actions">
|
||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="automation-editor-body">
|
||||
<div className="automation-editor-form">
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>1 · 什么时候触发</h3>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row">
|
||||
<div>
|
||||
<span className="automation-field-label">执行频率</span>
|
||||
<span className="automation-static-value">每天</span>
|
||||
<small>当前定时日报只支持每天执行一次。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row">
|
||||
<div>
|
||||
<span className="automation-field-label">执行时间</span>
|
||||
<div className="automation-scheduled-time">
|
||||
<Input
|
||||
aria-label="执行时间小时"
|
||||
inputMode="numeric"
|
||||
value={hourText}
|
||||
onChange={(event) => commitHour(event.target.value)}
|
||||
onBlur={() =>
|
||||
setHourText(String(parseScheduleTime(draft.scheduleTime)[0]).padStart(2, '0'))
|
||||
}
|
||||
/>
|
||||
<span aria-hidden="true">:</span>
|
||||
<Input
|
||||
aria-label="执行时间分钟"
|
||||
inputMode="numeric"
|
||||
value={minuteText}
|
||||
onChange={(event) => commitMinute(event.target.value)}
|
||||
onBlur={() =>
|
||||
setMinuteText(
|
||||
String(parseScheduleTime(draft.scheduleTime)[1]).padStart(2, '0')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row">
|
||||
<div>
|
||||
<span className="automation-field-label">预计下次执行</span>
|
||||
<span className="automation-static-value" data-testid="scheduled-next-run">
|
||||
{formatScheduledSlot(draft.scheduleTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- 2 · 完整日报配置(与旧「定时日报」对齐,一项不删)---------- */}
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>2 · 生成什么日报</h3>
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">任务名称</span>
|
||||
<Input
|
||||
aria-label="定时日报任务名称"
|
||||
value={draft.name}
|
||||
placeholder="例如:TraceMemo 每日晚报"
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">日报来源</span>
|
||||
<small>生成哪个群的日报。</small>
|
||||
<RadioGroup
|
||||
value={draft.group}
|
||||
onValueChange={(value) =>
|
||||
patch({
|
||||
group: value,
|
||||
target: draft.targetType === 'source_chat' ? value : draft.target
|
||||
})
|
||||
}
|
||||
aria-label="日报来源"
|
||||
className="automation-leave-contact-picker"
|
||||
>
|
||||
<Input
|
||||
value={groupFilter}
|
||||
onChange={(event) => setGroupFilter(event.target.value)}
|
||||
placeholder="搜索群聊"
|
||||
aria-label="搜索群聊"
|
||||
/>
|
||||
<div className="automation-leave-contact-list">
|
||||
{visibleGroups.length === 0 ? (
|
||||
<p className="automation-group-empty">没有可选的群聊</p>
|
||||
) : (
|
||||
visibleGroups.map((group) => (
|
||||
<div key={group.id} className="automation-leave-radio option">
|
||||
<RadioGroupItem value={group.name} id={`scheduled-source-${group.id}`} />
|
||||
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">日报范围</span>
|
||||
<Select
|
||||
value={draft.reportRange}
|
||||
onValueChange={(value) =>
|
||||
patch({ reportRange: value as ScheduledReportDraft['reportRange'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="日报范围">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULED_REPORT_RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 复用旧日报页的 MessageTypeSelector:类型 / 说明 /「至少选择一种」都来自真实定义。 */}
|
||||
<MessageTypeSelector
|
||||
value={draft.messageTypes}
|
||||
disabled={saving}
|
||||
onChange={(next) => patch({ messageTypes: next })}
|
||||
/>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">日报内容</span>
|
||||
<small>
|
||||
与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、
|
||||
活跃成员、活跃时间分布、关键词和内容密度。
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row automation-model-row">
|
||||
<div>
|
||||
<span className="automation-field-label">模型配置</span>
|
||||
<small>使用当前日报默认的文字总结和图片理解模型。</small>
|
||||
</div>
|
||||
{onOpenModelSettings ? (
|
||||
<Button variant="link" size="sm" onClick={onOpenModelSettings}>
|
||||
更改模型
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">日报模板</span>
|
||||
<Select
|
||||
value={draft.templateId}
|
||||
onValueChange={(value) =>
|
||||
patch({ templateId: value as ScheduledReportDraft['templateId'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="日报模板">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">成员名称</span>
|
||||
<Select
|
||||
value={draft.memberNameMode}
|
||||
onValueChange={(value) =>
|
||||
patch({ memberNameMode: value as ScheduledReportDraft['memberNameMode'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="成员名称">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULED_REPORT_MEMBER_NAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">日报生成超时</span>
|
||||
<div className="automation-scheduled-timeout">
|
||||
<Input
|
||||
aria-label="日报生成超时"
|
||||
inputMode="numeric"
|
||||
value={String(draft.timeoutSeconds)}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value.replace(/\D/g, '').slice(0, 4))
|
||||
patch({ timeoutSeconds: Number.isNaN(next) ? draft.timeoutSeconds : next })
|
||||
}}
|
||||
/>
|
||||
<span>秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- 3 · 发送目标四选一(不提供"指定其他群聊")---------- */}
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>3 · 生成后发送到哪里</h3>
|
||||
</div>
|
||||
<p className="automation-section-lead">
|
||||
生成哪个群的日报,与把日报发到哪里,是两件事。
|
||||
</p>
|
||||
|
||||
<RadioGroup
|
||||
value={draft.targetType}
|
||||
onValueChange={(value) => selectTargetType(value as ScheduledReportTargetType)}
|
||||
aria-label="生成后发送到哪里"
|
||||
className="automation-leave-targets"
|
||||
>
|
||||
{SCHEDULED_REPORT_TARGET_OPTIONS.map((option) => (
|
||||
<div key={option.type} className="automation-leave-radio option">
|
||||
<RadioGroupItem value={option.type} id={`scheduled-target-${option.type}`} />
|
||||
<div className="automation-leave-radio-body">
|
||||
<label htmlFor={`scheduled-target-${option.type}`}>{option.label}</label>
|
||||
<small>{option.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{draft.targetType === 'contact' ? (
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">指定好友</span>
|
||||
<LeaveNotificationTargetPicker
|
||||
contacts={sendableContacts}
|
||||
selectedId={draft.targetContactId}
|
||||
onSelect={(id) => patch({ targetContactId: id })}
|
||||
ariaLabel="指定好友"
|
||||
searchPlaceholder="搜索好友"
|
||||
emptyText="没有可发送的联系人"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="automation-editor-footnote">
|
||||
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
|
||||
TraceMemo 本身不额外保存或转发内容。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ScheduledReportPreview
|
||||
draft={draft}
|
||||
groupLabel={resolveGroupDisplayName(draft.group, groups)}
|
||||
contactName={selectedContactName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { SUMMARY_TYPE_OPTIONS } from '../../utils/group-report'
|
||||
import {
|
||||
formatScheduledSlot,
|
||||
scheduledReportRangeLabel,
|
||||
scheduledReportTemplateLabel,
|
||||
type ScheduledReportDraft,
|
||||
type ScheduledReportTargetType
|
||||
} from './model/scheduled-report-preview'
|
||||
|
||||
/**
|
||||
* ScheduledReportPreview —— 右侧「效果预览」。
|
||||
*
|
||||
* 与左侧表单逐项联动,**绝不执行任何真实动作**:
|
||||
* 不调 scheduler、不生成日报、不发送、不碰 store。
|
||||
*
|
||||
* 定时日报用「流程」表达比微信气泡准确:一次执行是
|
||||
* 定时触发 → 生成日报 → 模型 → 模板 → 发送 的链条,而不是一段对话。
|
||||
*/
|
||||
|
||||
export interface ScheduledReportPreviewProps {
|
||||
draft: ScheduledReportDraft
|
||||
/** 来源群的**显示名**(草稿里存的可能是内部标识,展示前已在调用方解析)。 */
|
||||
groupLabel: string
|
||||
/** 指定好友的显示名。 */
|
||||
contactName: string
|
||||
}
|
||||
|
||||
export function ScheduledReportPreview({
|
||||
draft,
|
||||
groupLabel,
|
||||
contactName
|
||||
}: ScheduledReportPreviewProps): React.ReactElement {
|
||||
const groupText = groupLabel.trim() || '未选择群聊'
|
||||
|
||||
/** 发送目标的显示文案(四种类型各取真实语义,不回落成内部 id)。 */
|
||||
const targetText = React.useMemo((): string => {
|
||||
const labels: Record<ScheduledReportTargetType, string> = {
|
||||
source_chat: groupText,
|
||||
self: '我',
|
||||
file_transfer: '文件传输助手',
|
||||
contact: contactName.trim() || '未选择好友'
|
||||
}
|
||||
return labels[draft.targetType]
|
||||
}, [draft.targetType, groupText, contactName])
|
||||
|
||||
/**
|
||||
* 纳入的消息类型只做**摘要**:左侧才是详细配置,
|
||||
* 右侧逐条列出来会把预览拉得很长。
|
||||
*/
|
||||
const messageTypesSummary = React.useMemo((): string => {
|
||||
if (draft.messageTypes.length === 0) return '未选择'
|
||||
if (draft.messageTypes.length === SUMMARY_TYPE_OPTIONS.length) return '全部类型'
|
||||
return draft.messageTypes
|
||||
.map((value) => SUMMARY_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value)
|
||||
.join('、')
|
||||
}, [draft.messageTypes])
|
||||
|
||||
return (
|
||||
<section className="automation-preview" aria-label="效果模拟预览">
|
||||
<div className="automation-preview-heading">
|
||||
<h3>效果预览</h3>
|
||||
<span className="automation-preview-badge">仅预览,不会真实执行</span>
|
||||
</div>
|
||||
|
||||
<ol className="automation-preview-flow">
|
||||
<li className="automation-preview-flow-step">
|
||||
<span className="automation-preview-flow-time">{draft.scheduleTime}</span>
|
||||
<span className="automation-preview-flow-title">定时任务触发</span>
|
||||
<small>每天 {draft.scheduleTime}</small>
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-arrow" aria-hidden="true">
|
||||
↓
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-step">
|
||||
<span className="automation-preview-flow-title">生成日报</span>
|
||||
<small>来源:{groupText}</small>
|
||||
<small>范围:{scheduledReportRangeLabel(draft.reportRange)}</small>
|
||||
<small>纳入消息:{messageTypesSummary}</small>
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-arrow" aria-hidden="true">
|
||||
↓
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-step">
|
||||
<span className="automation-preview-flow-title">模型</span>
|
||||
<small>默认日报模型</small>
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-arrow" aria-hidden="true">
|
||||
↓
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-step">
|
||||
<span className="automation-preview-flow-title">模板</span>
|
||||
<small>{scheduledReportTemplateLabel(draft.templateId)}</small>
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-arrow" aria-hidden="true">
|
||||
↓
|
||||
</li>
|
||||
|
||||
<li className="automation-preview-flow-step">
|
||||
<span className="automation-preview-flow-title">发送到</span>
|
||||
<small>{targetText}</small>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */}
|
||||
<div className="automation-preview-report-card" aria-hidden="true">
|
||||
<div className="automation-preview-report-head">
|
||||
<span>{groupText}</span>
|
||||
<span>{scheduledReportRangeLabel(draft.reportRange)}</span>
|
||||
</div>
|
||||
<div className="automation-preview-report-line wide" />
|
||||
<div className="automation-preview-report-line" />
|
||||
<div className="automation-preview-report-line" />
|
||||
<div className="automation-preview-report-line short" />
|
||||
</div>
|
||||
|
||||
<dl className="automation-preview-facts">
|
||||
<div>
|
||||
<dt>触发方式</dt>
|
||||
<dd>每天 {draft.scheduleTime}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>日报来源</dt>
|
||||
<dd>{groupText}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>日报范围</dt>
|
||||
<dd>{scheduledReportRangeLabel(draft.reportRange)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>日报模板</dt>
|
||||
<dd>{scheduledReportTemplateLabel(draft.templateId)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送目标</dt>
|
||||
<dd>{targetText}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="automation-preview-footnote">
|
||||
预计下次执行:{formatScheduledSlot(draft.scheduleTime)}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button, Spinner, Switch } from '../../components/ui'
|
||||
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
|
||||
import {
|
||||
formatNextRunAt,
|
||||
scheduledReportRangeLabel,
|
||||
scheduledReportTemplateLabel
|
||||
} from './model/scheduled-report-preview'
|
||||
|
||||
/**
|
||||
* ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。
|
||||
*
|
||||
* 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则
|
||||
* (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。
|
||||
*
|
||||
* 所有写操作(立即执行 / 启停 / 删除)都不落盘,只提示。
|
||||
* 数据是**只读**拿到的真实任务(见 `model/scheduled-report-preview.ts`)。
|
||||
*/
|
||||
|
||||
export interface ScheduledReportRuleListProps {
|
||||
tasks: ScheduledReportTask[]
|
||||
loading: boolean
|
||||
/** 正在"操作中"的任务 id(仅用于视觉反馈,不产生真实副作用)。 */
|
||||
busyTaskId: string | null
|
||||
/**
|
||||
* 群标识 → 显示名。
|
||||
*
|
||||
* 任务里存的可能是群名、room id 或 hash;列表**绝不能**把内部标识露给用户。
|
||||
*/
|
||||
resolveGroupDisplay: (raw: string) => string
|
||||
onCreate: () => void
|
||||
onEdit: (task: ScheduledReportTask) => void
|
||||
onRunNow: (task: ScheduledReportTask) => void
|
||||
onToggle: (task: ScheduledReportTask, enabled: boolean) => void
|
||||
onDelete: (task: ScheduledReportTask) => void
|
||||
}
|
||||
|
||||
export function ScheduledReportRuleList({
|
||||
tasks,
|
||||
loading,
|
||||
busyTaskId,
|
||||
resolveGroupDisplay,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onRunNow,
|
||||
onToggle,
|
||||
onDelete
|
||||
}: ScheduledReportRuleListProps): React.ReactElement {
|
||||
const runningCount = tasks.filter((task) => task.enabled).length
|
||||
|
||||
return (
|
||||
<div className="automation-rule-list">
|
||||
<section className="automation-rule-group">
|
||||
<div className="automation-section-heading">
|
||||
<h2>定时日报</h2>
|
||||
<span className="automation-section-note">
|
||||
{tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="automation-section-lead">
|
||||
按设定时间自动生成日报,并发送到指定微信会话。
|
||||
</p>
|
||||
|
||||
<div className="automation-scheduled-toolbar">
|
||||
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||
{tasks.length > 0 ? (
|
||||
<span className="automation-section-note">
|
||||
运行中 {runningCount} / {tasks.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="automation-loading">
|
||||
<Spinner />
|
||||
<span>正在读取定时日报…</span>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="automation-empty-card">
|
||||
<p>还没有定时日报</p>
|
||||
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
|
||||
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<article
|
||||
key={task.id}
|
||||
className={`automation-rule-card ${task.enabled ? '' : 'disabled'}`}
|
||||
>
|
||||
<div className="automation-rule-card-main">
|
||||
<div className="automation-rule-card-title">
|
||||
<h3>{task.name}</h3>
|
||||
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
|
||||
<span className={`automation-rule-state ${task.enabled ? 'on' : 'off'}`}>
|
||||
{task.enabled ? '运行中' : '已暂停'}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="automation-rule-meta">
|
||||
<div>
|
||||
<dt>触发</dt>
|
||||
<dd>每天 {task.scheduleTime}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>日报</dt>
|
||||
<dd>{scheduledReportRangeLabel(task.reportRange)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>来源</dt>
|
||||
<dd>{resolveGroupDisplay(task.group) || '未设置'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>模板</dt>
|
||||
<dd>{scheduledReportTemplateLabel(task.templateId)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送到</dt>
|
||||
{/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */}
|
||||
<dd>
|
||||
{!task.target || task.target === task.group
|
||||
? '日报来源群'
|
||||
: resolveGroupDisplay(task.target)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>下次执行</dt>
|
||||
<dd>{task.enabled ? formatNextRunAt(task.nextRunAt) : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="automation-rule-card-side">
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
disabled={busyTaskId === task.id}
|
||||
onCheckedChange={(checked) => onToggle(task, checked)}
|
||||
aria-label={`${task.name} 启停`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busyTaskId === task.id}
|
||||
onClick={() => onRunNow(task)}
|
||||
>
|
||||
立即执行
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(task)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="link" size="sm" onClick={() => onDelete(task)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
REPORT_TEMPLATES,
|
||||
SELECTABLE_REPORT_TEMPLATES,
|
||||
getReportTemplate,
|
||||
type SelectableReportTemplateId
|
||||
} from '../../../../../shared/report-templates'
|
||||
import type {
|
||||
ScheduledReportMemberNameMode,
|
||||
ScheduledReportRange,
|
||||
ScheduledReportTask
|
||||
} from '../../../../../shared/scheduled-report'
|
||||
import { SUMMARY_TYPE_OPTIONS, type SummaryMessageType } from '../../../utils/group-report'
|
||||
|
||||
/**
|
||||
* 定时日报编辑态的模型与只读数据层。
|
||||
*
|
||||
* 对外唯一的接触面是 `window.api.listScheduledReports`(**只读**):
|
||||
* 不写、不执行、不动 scheduler / store / schema。
|
||||
*
|
||||
* 所有字段都来自 `shared/scheduled-report.ts` 的真实模型,**不发明后端不存在的选项**:
|
||||
* - `schedule.type` 只有 `'daily'` ⇒ 界面只给"每天 + 时间";
|
||||
* - `target` 是单个群(`type: 'wechat_group'`)⇒ 界面只给群聊目标;
|
||||
* - `templateId` 真实存在 ⇒ 界面可以给模板选择。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 编辑器的草稿:字段与**旧「定时日报」真实配置**一一对应,
|
||||
* 没有任何"想象出来的"配置,也没有丢掉任何一项已有能力。
|
||||
*/
|
||||
export interface ScheduledReportDraft {
|
||||
name: string
|
||||
/** 每天的执行时间 `HH:mm`,对应真实的 `schedule: { type: 'daily', time }`。 */
|
||||
scheduleTime: string
|
||||
reportRange: ScheduledReportRange
|
||||
/** 日报来源群:生成**哪个群**的日报。 */
|
||||
group: string
|
||||
/** 纳入的消息类型(真实 `messageTypes`)。 */
|
||||
messageTypes: SummaryMessageType[]
|
||||
/** 成员名称显示方式(真实 `memberNameMode`)。 */
|
||||
memberNameMode: ScheduledReportMemberNameMode
|
||||
/** 日报生成超时(真实 `timeoutSeconds`,旧页面 30–1800 秒)。 */
|
||||
timeoutSeconds: number
|
||||
templateId: SelectableReportTemplateId
|
||||
/**
|
||||
* 发送目标的**类型**。
|
||||
*
|
||||
* 这是本层的概念:真实的 `ScheduledReportTask.target` 只是一个字符串
|
||||
* (目前只表达"某个微信群")。四选一里的「自己 / 文件传输助手 / 指定好友」
|
||||
* 在真实模型里还没有对应的类型化表达 —— 真实迁移时应当照退群通知的做法,
|
||||
* 给 target 一个带类型的模型与解析器。
|
||||
*/
|
||||
targetType: ScheduledReportTargetType
|
||||
/** `targetType === 'source_chat'` 时的目标群(= 来源群,旧页面就是这个语义)。 */
|
||||
target: string
|
||||
/** `targetType === 'contact'` 时的联系人。 */
|
||||
targetContactId: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/** 全部消息类型(旧页面默认全选)。 */
|
||||
export const ALL_SCHEDULED_REPORT_MESSAGE_TYPES: SummaryMessageType[] = SUMMARY_TYPE_OPTIONS.map(
|
||||
(option) => option.value
|
||||
)
|
||||
|
||||
/** 成员名称选项(与旧页面一致)。 */
|
||||
export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{
|
||||
value: ScheduledReportMemberNameMode
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'wechatNickname', label: '微信昵称' },
|
||||
{ value: 'remark', label: '通讯录备注' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 发送目标四选一。
|
||||
*
|
||||
* 第一项与退群通知不同:定时日报没有"当前消息所在群"这个概念,
|
||||
* 所以是「日报来源群」。刻意**不提供"指定其他群聊"** ——
|
||||
* 生成 A 群日报却发到 B 群是很容易让人误解的配置。
|
||||
*/
|
||||
export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
|
||||
|
||||
export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{
|
||||
type: ScheduledReportTargetType
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
type: 'file_transfer',
|
||||
label: '文件传输助手',
|
||||
description: '将生成的日报发送到文件传输助手。'
|
||||
},
|
||||
{
|
||||
type: 'source_chat',
|
||||
label: '发送到日报来源群',
|
||||
description: '把生成的日报发送回用于生成日报的那个群。'
|
||||
},
|
||||
{
|
||||
type: 'self',
|
||||
label: '发给自己',
|
||||
description: '将生成的日报发送到当前登录微信账号自己的会话。'
|
||||
},
|
||||
{
|
||||
type: 'contact',
|
||||
label: '指定好友',
|
||||
description: '将生成的日报发送给一个指定联系人。'
|
||||
}
|
||||
]
|
||||
|
||||
/** 新建时的默认发送目标:文件传输助手(不会误打扰群聊)。 */
|
||||
export const DEFAULT_SCHEDULED_REPORT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer'
|
||||
|
||||
/** 模板选项:与旧页面同一套定义(默认模板 + REPORT_TEMPLATES)。 */
|
||||
export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{
|
||||
value: SelectableReportTemplateId
|
||||
label: string
|
||||
}> = [
|
||||
{ value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name },
|
||||
...REPORT_TEMPLATES.map((template) => ({
|
||||
value: template.id,
|
||||
label: `${template.label} · ${template.name}`
|
||||
}))
|
||||
]
|
||||
|
||||
/** 日报范围选项。文案与旧「日报 → 定时日报」页面保持一致。 */
|
||||
export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{
|
||||
value: ScheduledReportRange
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'today', label: '今日' },
|
||||
{ value: 'yesterday', label: '昨日' },
|
||||
{ value: '7days', label: '近 7 天' },
|
||||
{ value: 'recent24h', label: '最近24小时' }
|
||||
]
|
||||
|
||||
/** 可选日报模板(真实定义来自 `report-templates.ts`)。 */
|
||||
export const SCHEDULED_REPORT_TEMPLATE_OPTIONS = SELECTABLE_REPORT_TEMPLATES.map((template) => ({
|
||||
value: template.id,
|
||||
label: template.label
|
||||
}))
|
||||
|
||||
export const DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID: SelectableReportTemplateId = 'v1'
|
||||
|
||||
/** 与旧页面同语义的范围文案(`近 7 天` 的空格等细节刻意保持一致)。 */
|
||||
export function scheduledReportRangeLabel(range: ScheduledReportRange): string {
|
||||
return (
|
||||
SCHEDULED_REPORT_RANGE_OPTIONS.find((option) => option.value === range)?.label ??
|
||||
SCHEDULED_REPORT_RANGE_OPTIONS[0].label
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间展示:`今日 18:30` / `明日 18:30`。
|
||||
*
|
||||
* 这里只服务**本地草稿预览**(用户刚改完时间就要看到结果),
|
||||
* 所以用"今日/明日"表达相对关系;真实任务的 `nextRunAt` 展示沿用旧页面语义。
|
||||
*/
|
||||
export function formatScheduledSlot(scheduleTime: string, now = new Date()): string {
|
||||
const [hour, minute] = parseScheduleTime(scheduleTime)
|
||||
const slot = new Date(now)
|
||||
slot.setSeconds(0, 0)
|
||||
slot.setHours(hour, minute)
|
||||
if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1)
|
||||
const isToday = slot.toDateString() === now.toDateString()
|
||||
return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}`
|
||||
}
|
||||
|
||||
/** 把真实的 `nextRunAt` 格式化成旧页面同款文案。 */
|
||||
export function formatNextRunAt(value?: string): string {
|
||||
if (!value) return '尚未执行'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '时间未知'
|
||||
const now = new Date()
|
||||
const dayLabel =
|
||||
date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN')
|
||||
return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
|
||||
}
|
||||
|
||||
export function parseScheduleTime(value: string): [number, number] {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim())
|
||||
if (!matched) return [18, 30]
|
||||
const hour = Math.min(23, Math.max(0, Number(matched[1])))
|
||||
const minute = Math.min(59, Math.max(0, Number(matched[2])))
|
||||
return [hour, minute]
|
||||
}
|
||||
|
||||
export function composeScheduleTime(hour: number, minute: number): string {
|
||||
return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}`
|
||||
}
|
||||
|
||||
export function createEmptyScheduledReportDraft(): ScheduledReportDraft {
|
||||
return {
|
||||
name: '',
|
||||
scheduleTime: '18:30',
|
||||
reportRange: 'today',
|
||||
group: '',
|
||||
// 与旧页面一致:默认纳入全部消息类型。
|
||||
messageTypes: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES],
|
||||
memberNameMode: 'groupNickname',
|
||||
timeoutSeconds: 300,
|
||||
templateId: DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID,
|
||||
// 默认发到文件传输助手:不会误打扰任何群聊。
|
||||
targetType: DEFAULT_SCHEDULED_REPORT_TARGET_TYPE,
|
||||
target: '',
|
||||
targetContactId: '',
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实任务 → 编辑器草稿(只用于**预览**,保存不落盘)。 */
|
||||
export function createDraftFromTask(task: ScheduledReportTask): ScheduledReportDraft {
|
||||
return {
|
||||
name: task.name,
|
||||
scheduleTime: task.scheduleTime,
|
||||
reportRange: task.reportRange,
|
||||
group: task.group,
|
||||
messageTypes: task.messageTypes?.length
|
||||
? (task.messageTypes as SummaryMessageType[])
|
||||
: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES],
|
||||
memberNameMode: task.memberNameMode ?? 'groupNickname',
|
||||
timeoutSeconds: task.timeoutSeconds ?? 300,
|
||||
templateId: task.templateId ?? DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID,
|
||||
// 旧页面里「发送到」恒等于来源群(只读展示),所以历史任务一律按"发回来源群"回显。
|
||||
targetType: 'source_chat',
|
||||
target: task.target,
|
||||
targetContactId: '',
|
||||
enabled: task.enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任务里存的群标识解析成**能给用户看**的名字。
|
||||
*
|
||||
* `ScheduledReportTask.group/target` 的真实类型是 "human-readable group name or room id",
|
||||
* 实际数据里两种都可能出现(纯数字 room id 与 16 进制 hash)。
|
||||
* 直接渲染就会把内部标识暴露给用户,所以这里做一次显示名解析:
|
||||
* 能对上群名就用群名;明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。
|
||||
*/
|
||||
export function resolveGroupDisplayName(
|
||||
raw: string,
|
||||
groups: Array<{ id: string; name: string }>
|
||||
): string {
|
||||
const value = String(raw ?? '').trim()
|
||||
if (!value) return ''
|
||||
const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value)
|
||||
if (matched) return matched.name
|
||||
return looksLikeInternalIdentifier(value) ? '未知群聊' : value
|
||||
}
|
||||
|
||||
/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */
|
||||
function looksLikeInternalIdentifier(value: string): boolean {
|
||||
return (
|
||||
value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板的**展示名**。
|
||||
*
|
||||
* 用 `name`("经典日报")而不是 `label`("默认模板"):
|
||||
* 同一个模板在列表、预览、选择器里必须是同一个称呼,
|
||||
* 否则同一份配置会出现两种叫法。
|
||||
*/
|
||||
export function scheduledReportTemplateLabel(templateId?: string): string {
|
||||
return getReportTemplate(templateId).name
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读加载现有定时日报任务。
|
||||
*
|
||||
* **唯一的读取入口**,也是本文件与真实系统唯一的接触面:
|
||||
* - 不调用 create / update / delete / execute / toggle;
|
||||
* - 接口不存在或失败时返回空数组,让界面走空态,而不是伪造数据。
|
||||
*/
|
||||
export async function loadScheduledReportTasks(): Promise<ScheduledReportTask[]> {
|
||||
const target = (globalThis as { api?: Record<string, unknown> }).api
|
||||
const loader = target?.listScheduledReports
|
||||
if (typeof loader !== 'function') return []
|
||||
try {
|
||||
const tasks = await (loader as () => Promise<unknown>).call(target)
|
||||
return Array.isArray(tasks) ? (tasks as ScheduledReportTask[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
@@ -1483,3 +1483,195 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 定时日报(只读)---------- */
|
||||
|
||||
/* 标题下的引导语,与卡片区之间留一点呼吸。 */
|
||||
.automation-section-lead {
|
||||
margin: -2px 0 2px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.automation-scheduled-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
|
||||
/* 「执行时间」的 时:分 —— 两个窄输入 + 冒号。 */
|
||||
.automation-scheduled-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 预览里的执行流程:定时触发 → 生成日报 → 发送图片。
|
||||
* 定时日报是一次「任务」而不是一段对话,用流程表达比微信气泡准确。
|
||||
*/
|
||||
.automation-preview-flow {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
list-style: none;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-app);
|
||||
}
|
||||
|
||||
.automation-preview-flow-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.automation-preview-flow-time {
|
||||
color: var(--wxex-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.automation-preview-flow-title {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.automation-preview-flow-step small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.automation-preview-flow-arrow {
|
||||
padding-left: 1px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */
|
||||
.automation-preview-report-card {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.automation-preview-report-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.automation-preview-report-line {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wxex-text-muted) 22%, transparent);
|
||||
|
||||
&.wide {
|
||||
width: 82%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 46%;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-preview-footnote {
|
||||
margin: 10px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
/* 「日报 → 定时日报」页顶部的入口,跳到「自动化 → 规则 → 定时日报」。 */
|
||||
.report-scheduled-automation-entry {
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--wxex-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 定时日报编辑器 ---------- */
|
||||
|
||||
/* 「← 返回定时日报」:回的是规则列表,不是自动化首页。 */
|
||||
.automation-editor-back {
|
||||
align-self: flex-start;
|
||||
margin-bottom: 2px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--wxex-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 模型配置:说明在左,「更改模型」在右。 */
|
||||
.automation-model-row {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.automation-scheduled-timeout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-editor-footnote {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ToastProvider } from '../../src/renderer/src/components/ui'
|
||||
import {
|
||||
AutomationWorkspace,
|
||||
type AutomationOpenRuleRequest
|
||||
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
||||
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
|
||||
import {
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule
|
||||
} from '../../src/shared/automation'
|
||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
||||
|
||||
/**
|
||||
* 「自动化 → 规则 → 定时日报」。
|
||||
*
|
||||
* 这一组测试要守住的核心不是"界面长什么样",而是**边界**:
|
||||
* 任何写操作都不许落到真实定时日报系统上
|
||||
* (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。
|
||||
*/
|
||||
|
||||
const TASKS: ScheduledReportTask[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
name: 'TraceMemo 每日晚报',
|
||||
group: 'TraceMemo 交流群',
|
||||
scheduleTime: '18:21',
|
||||
reportRange: 'today',
|
||||
target: 'TraceMemo 管理群',
|
||||
enabled: true,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||
nextRunAt: '2026-09-24T10:21:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
name: '技术交流群日报',
|
||||
group: '技术交流群',
|
||||
scheduleTime: '09:00',
|
||||
reportRange: 'yesterday',
|
||||
target: '技术交流群',
|
||||
enabled: false,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||
nextRunAt: '2026-09-25T01:00:00.000Z'
|
||||
}
|
||||
]
|
||||
|
||||
interface Spies {
|
||||
createScheduledReport: ReturnType<typeof vi.fn>
|
||||
updateScheduledReport: ReturnType<typeof vi.fn>
|
||||
deleteScheduledReport: ReturnType<typeof vi.fn>
|
||||
setScheduledReportEnabled: ReturnType<typeof vi.fn>
|
||||
runScheduledReportNow: ReturnType<typeof vi.fn>
|
||||
retryScheduledReportSend: ReturnType<typeof vi.fn>
|
||||
testScheduledReportErrorNotification: ReturnType<typeof vi.fn>
|
||||
listScheduledReports: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */
|
||||
function writeSpies(spies: Spies): Array<[string, ReturnType<typeof vi.fn>]> {
|
||||
return [
|
||||
['createScheduledReport', spies.createScheduledReport],
|
||||
['updateScheduledReport', spies.updateScheduledReport],
|
||||
['deleteScheduledReport', spies.deleteScheduledReport],
|
||||
['setScheduledReportEnabled', spies.setScheduledReportEnabled],
|
||||
['runScheduledReportNow', spies.runScheduledReportNow],
|
||||
['retryScheduledReportSend', spies.retryScheduledReportSend],
|
||||
['testScheduledReportErrorNotification', spies.testScheduledReportErrorNotification]
|
||||
]
|
||||
}
|
||||
|
||||
function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
|
||||
const spies: Spies = {
|
||||
listScheduledReports: vi.fn().mockResolvedValue(tasks),
|
||||
createScheduledReport: vi.fn(),
|
||||
updateScheduledReport: vi.fn(),
|
||||
deleteScheduledReport: vi.fn(),
|
||||
setScheduledReportEnabled: vi.fn(),
|
||||
runScheduledReportNow: vi.fn(),
|
||||
retryScheduledReportSend: vi.fn(),
|
||||
testScheduledReportErrorNotification: vi.fn()
|
||||
}
|
||||
|
||||
window.api = {
|
||||
getAutomationStatus: vi.fn().mockResolvedValue({
|
||||
listening: true,
|
||||
listeningDegraded: false,
|
||||
todayExecutions: 0,
|
||||
todaySuccesses: 0,
|
||||
sendCapability: {
|
||||
supported: true,
|
||||
ready: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
message: 'ready'
|
||||
}
|
||||
}),
|
||||
listAutomationRules: vi.fn().mockResolvedValue([
|
||||
createDefaultDailyReportRule(Date.now()),
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
]),
|
||||
listAutomationGroups: vi.fn().mockResolvedValue([
|
||||
{ id: 'g1', name: 'TraceMemo 交流群' },
|
||||
{ id: 'g2', name: '技术交流群' },
|
||||
{ id: 'g3', name: 'TraceMemo 管理群' }
|
||||
]),
|
||||
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
||||
listSendableContacts: vi.fn().mockResolvedValue([]),
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue({
|
||||
events: [],
|
||||
enabled: true,
|
||||
running: true,
|
||||
nativeMonitorActive: true,
|
||||
monitoredGroupCount: 2,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [],
|
||||
lastReadAt: 0,
|
||||
unreadCount: 0
|
||||
}),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
// 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。
|
||||
listScheduledReportExecutions: vi.fn().mockResolvedValue([]),
|
||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
|
||||
getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
...spies
|
||||
} as unknown as typeof window.api
|
||||
|
||||
return spies
|
||||
}
|
||||
|
||||
const renderWorkspace = (request?: AutomationOpenRuleRequest): void => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<AutomationWorkspace
|
||||
dbReady
|
||||
openRuleRequest={request ?? null}
|
||||
onOpenModelSettings={() => {}}
|
||||
/>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** 打开「规则 → 定时日报」。 */
|
||||
async function openScheduledTab(): Promise<void> {
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '新建自动化' }))
|
||||
await user.click(screen.getByRole('radio', { name: '定时日报' }))
|
||||
}
|
||||
|
||||
describe('自动化 · 规则类型 Tab(定时日报)', () => {
|
||||
it('Tab 有三项,顺序是 消息 → 时间 → 系统事件', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await screen.findByText('退群通知')
|
||||
await userEvent.setup().click(screen.getAllByRole('button', { name: '新建自动化' })[0])
|
||||
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll('.automation-rule-type-tabs [role="radio"]')
|
||||
).map((el) => el.textContent?.trim())
|
||||
expect(tabs).toEqual(['@我生成日报', '定时日报', '退群通知'])
|
||||
})
|
||||
|
||||
it('切到定时日报先给规则列表,不直接进编辑器', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible()
|
||||
expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible()
|
||||
expect(screen.getByText('2 条规则')).toBeVisible()
|
||||
// 列表态不该出现编辑器的保存按钮。
|
||||
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
|
||||
})
|
||||
|
||||
it('列表按真实字段渲染(触发 / 日报 / 来源 / 发送到 / 下次执行)', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
const meta = Array.from(card.querySelectorAll('dl > div')).map((row) => [
|
||||
row.querySelector('dt')?.textContent?.trim(),
|
||||
row.querySelector('dd')?.textContent?.trim()
|
||||
])
|
||||
|
||||
expect(meta).toEqual([
|
||||
['触发', '每天 18:21'],
|
||||
['日报', '今日'],
|
||||
['来源', 'TraceMemo 交流群'],
|
||||
['模板', '经典日报'],
|
||||
// target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。
|
||||
['发送到', 'TraceMemo 管理群'],
|
||||
['下次执行', expect.any(String)]
|
||||
])
|
||||
// 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。
|
||||
const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement
|
||||
expect(paused.textContent).toContain('已暂停')
|
||||
expect(within(paused).getByText('—')).toBeVisible()
|
||||
// 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。
|
||||
expect(paused.textContent).toContain('日报来源群')
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 编辑器与预览联动', () => {
|
||||
it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible()
|
||||
// 只有一个开关:规则级启停。
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('点卡片「编辑」进入编辑器,并带出该任务的值', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
||||
|
||||
expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报')
|
||||
expect(screen.getByLabelText('执行时间小时')).toHaveValue('18')
|
||||
expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21')
|
||||
expect(
|
||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||
name: 'TraceMemo 交流群'
|
||||
})
|
||||
).toBeChecked()
|
||||
})
|
||||
|
||||
/*
|
||||
* 任务里存的群名与群列表是两个数据源,不保证一致
|
||||
* (群被移出监控、或列表来源变了)。这时**必须回显当前值**,
|
||||
* 否则编辑已有任务会出现"一串群里没有任何一项被选中"。
|
||||
*/
|
||||
it('来源群不在群列表里时,仍会作为候选项被选中', async () => {
|
||||
installApi([{ ...TASKS[0], group: '已不在列表的群', target: '已不在列表的群' }])
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
||||
|
||||
expect(
|
||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||
name: '已不在列表的群'
|
||||
})
|
||||
).toBeChecked()
|
||||
})
|
||||
|
||||
it('改执行时间 → 预览与「预计下次执行」同时更新', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
const hourInput = screen.getByLabelText('执行时间小时')
|
||||
await user.clear(hourInput)
|
||||
await user.type(hourInput, '07')
|
||||
const minuteInput = screen.getByLabelText('执行时间分钟')
|
||||
await user.clear(minuteInput)
|
||||
await user.type(minuteInput, '05')
|
||||
|
||||
expect(screen.getByTestId('scheduled-next-run').textContent).toMatch(/0?7:05/)
|
||||
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
|
||||
expect(facts).toContain('每天 07:05')
|
||||
})
|
||||
|
||||
it('改日报来源 → 预览的生成目标跟着变', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||
name: '技术交流群'
|
||||
})
|
||||
)
|
||||
|
||||
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
|
||||
expect(flow).toContain('技术交流群')
|
||||
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
|
||||
expect(facts).toContain('技术交流群')
|
||||
})
|
||||
|
||||
it('发送目标是四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
|
||||
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
|
||||
expect(labels).toEqual(['文件传输助手', '发送到日报来源群', '发给自己', '指定好友'])
|
||||
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
|
||||
|
||||
// 新建默认 = 文件传输助手(不会误打扰群聊)。
|
||||
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('保存只提示,不调用任何真实写接口', async () => {
|
||||
const spies = installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
|
||||
for (const [name, spy] of writeSpies(spies)) {
|
||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('「立即执行」不触发真实执行', async () => {
|
||||
const spies = installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
await user.click(within(card).getByRole('button', { name: '立即执行' }))
|
||||
|
||||
expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible()
|
||||
for (const [name, spy] of writeSpies(spies)) {
|
||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('toggle 只改本地状态并提示,不写回 store', async () => {
|
||||
const spies = installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
|
||||
await user.click(within(card).getByRole('switch'))
|
||||
|
||||
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
|
||||
expect(within(card).getByText('已暂停')).toBeVisible()
|
||||
expect(spies.setScheduledReportEnabled).not.toHaveBeenCalled()
|
||||
for (const [name, spy] of writeSpies(spies)) {
|
||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('删除只是提示,不移除任务', async () => {
|
||||
const spies = installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
await user.click(within(card).getByRole('button', { name: '删除' }))
|
||||
|
||||
expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible()
|
||||
expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible()
|
||||
for (const [name, spy] of writeSpies(spies)) {
|
||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('任务列表为空时给空态与新建入口', async () => {
|
||||
installApi([])
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
expect(await screen.findByText('还没有定时日报')).toBeVisible()
|
||||
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
|
||||
it('Section 2 承载旧「定时日报」的全部日报配置', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
expect(screen.getByLabelText('定时日报任务名称')).toBeVisible()
|
||||
expect(screen.getByRole('radiogroup', { name: '日报来源' })).toBeVisible()
|
||||
expect(screen.getByLabelText('日报范围')).toBeVisible()
|
||||
expect(screen.getByLabelText('日报模板')).toBeVisible()
|
||||
expect(screen.getByLabelText('成员名称')).toBeVisible()
|
||||
expect(screen.getByLabelText('日报生成超时')).toBeVisible()
|
||||
expect(screen.getByText('模型配置')).toBeVisible()
|
||||
expect(screen.getByText('更改模型')).toBeVisible()
|
||||
expect(screen.getByText('日报内容')).toBeVisible()
|
||||
})
|
||||
|
||||
it('纳入的消息类型用旧页面的真实类型与说明(默认全选)', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
expect(screen.getByText('纳入的消息类型')).toBeVisible()
|
||||
expect(screen.getByText('至少选择一种')).toBeVisible()
|
||||
|
||||
// 七类都来自 SUMMARY_TYPE_OPTIONS,不是新写的一套。
|
||||
for (const label of ['文本', '图片', '表情包', '视频', '语音', '分享/引用', '系统消息']) {
|
||||
expect(screen.getByRole('checkbox', { name: label })).toBeVisible()
|
||||
}
|
||||
// 旧页面默认全选。
|
||||
expect(screen.getByRole('checkbox', { name: '文本' })).toBeChecked()
|
||||
expect(screen.getByRole('checkbox', { name: '系统消息' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('成员名称保留旧页面的三个选项', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
await user.click(screen.getByLabelText('成员名称'))
|
||||
expect(await screen.findByRole('option', { name: '群昵称' })).toBeVisible()
|
||||
expect(screen.getByRole('option', { name: '微信昵称' })).toBeVisible()
|
||||
expect(screen.getByRole('option', { name: '通讯录备注' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 返回与四选一发送目标', () => {
|
||||
it('新建页有「← 返回定时日报」,点击回到规则列表', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
|
||||
// 回到「定时日报」列表,而不是自动化首页。
|
||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('编辑页也有返回入口,点击回到规则列表', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
||||
expect(screen.getByRole('button', { name: '← 返回定时日报' })).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
|
||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
||||
})
|
||||
|
||||
it('只有「指定好友」才展开好友选择器', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) {
|
||||
await user.click(screen.getByRole('radio', { name: label }))
|
||||
expect(screen.queryByPlaceholderText('搜索好友')).toBeNull()
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '指定好友' }))
|
||||
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
|
||||
})
|
||||
|
||||
it('Preview 随发送目标变化,且「发送到日报来源群」显示来源群名', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||
name: '技术交流群'
|
||||
})
|
||||
)
|
||||
|
||||
const targetOf = (): string => {
|
||||
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
|
||||
const row = rows.find((item) => item.querySelector('dt')?.textContent === '发送目标')
|
||||
return row?.querySelector('dd')?.textContent ?? ''
|
||||
}
|
||||
|
||||
expect(targetOf()).toBe('文件传输助手')
|
||||
await user.click(screen.getByRole('radio', { name: '发送到日报来源群' }))
|
||||
expect(targetOf()).toBe('技术交流群')
|
||||
await user.click(screen.getByRole('radio', { name: '发给自己' }))
|
||||
expect(targetOf()).toBe('我')
|
||||
})
|
||||
|
||||
it('Preview 随来源群与模板变化', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||
name: '技术交流群'
|
||||
})
|
||||
)
|
||||
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
|
||||
expect(flow).toContain('技术交流群')
|
||||
expect(flow).toContain('经典日报')
|
||||
|
||||
await user.click(screen.getByLabelText('日报模板'))
|
||||
// 选项文案来自 REPORT_TEMPLATES 的 `label · name`。
|
||||
await user.click(await screen.findByRole('option', { name: /Mobile 01/ }))
|
||||
expect(document.querySelector('.automation-preview-flow')?.textContent).not.toContain('经典日报')
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 不向用户暴露内部标识', () => {
|
||||
it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => {
|
||||
// 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制),
|
||||
// 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。
|
||||
const FAKE_ROOM_ID = '12345678@chatroom'
|
||||
const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
installApi([
|
||||
{ ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID },
|
||||
{ ...TASKS[1], group: FAKE_GROUP_HASH }
|
||||
])
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
await screen.findByText('TraceMemo 每日晚报')
|
||||
const body = document.body.textContent ?? ''
|
||||
expect(body).not.toContain(FAKE_ROOM_ID)
|
||||
expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12))
|
||||
// 对不上群名时给中性文案,而不是把 id 当名字。
|
||||
expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 首页汇总卡', () => {
|
||||
it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
|
||||
const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement
|
||||
expect(card.textContent).toContain('2 条规则 · 1 条运行中')
|
||||
expect(card.textContent).toContain('按设定时间')
|
||||
// 汇总卡不该有单个启停开关。
|
||||
expect(within(card).queryByRole('switch')).toBeNull()
|
||||
|
||||
await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' }))
|
||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报 · 深链与旧页面', () => {
|
||||
it('深链直接落到「自动化 → 规则 → 定时日报」', async () => {
|
||||
installApi()
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const [request] = React.useState<AutomationOpenRuleRequest>({
|
||||
ruleType: 'scheduled_report',
|
||||
requestId: 1
|
||||
})
|
||||
return <AutomationWorkspace dbReady openRuleRequest={request} />
|
||||
}
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Harness />
|
||||
</ToastProvider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => {
|
||||
installApi()
|
||||
const onOpenAutomation = vi.fn()
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[]}
|
||||
onOpenWechatSettings={() => {}}
|
||||
onOpenAgentHub={() => {}}
|
||||
onNotice={() => {}}
|
||||
onOpenAutomation={onOpenAutomation}
|
||||
/>
|
||||
</ToastProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '定时日报' })).toBeVisible()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: '在自动化中配置 →' }))
|
||||
expect(onOpenAutomation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
/** 让 `waitFor` 在本文件被使用(空态/异步断言需要)。 */
|
||||
describe('定时日报 · 异步加载', () => {
|
||||
it('加载完成后不再显示 loading', async () => {
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
await openScheduledTab()
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('正在读取定时日报…')).toBeNull())
|
||||
})
|
||||
})
|
||||
@@ -469,7 +469,55 @@ handle('automation:saveLeaveNotificationRule', (draft) => {
|
||||
return structuredClone(leaveNotificationRule)
|
||||
})
|
||||
|
||||
const scheduledReportTasks = []
|
||||
/*
|
||||
* 定时日报任务。
|
||||
*
|
||||
* 默认**为空**(既有 E2E 依赖这个初始状态)。
|
||||
* `WXE_E2E_SCHEDULED_FIXTURE=1` 启动时注入三条任务,
|
||||
* 让「自动化 → 定时日报」的列表与卡片走**真实读取路径**,而不是在渲染层塞假数据。
|
||||
*/
|
||||
const scheduledReportTasks =
|
||||
process.env.WXE_E2E_SCHEDULED_FIXTURE === '1'
|
||||
? [
|
||||
{
|
||||
id: 'fixture-scheduled-1',
|
||||
name: 'TraceMemo 每日晚报',
|
||||
group: 'TraceMemo 交流群',
|
||||
scheduleTime: '18:21',
|
||||
reportRange: 'today',
|
||||
target: 'TraceMemo 管理群',
|
||||
enabled: true,
|
||||
createdAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||||
updatedAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||||
lastRunAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||||
nextRunAt: new Date(fixtureNowMs + 3_600_000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'fixture-scheduled-2',
|
||||
name: '技术交流群日报',
|
||||
group: '技术交流群',
|
||||
scheduleTime: '09:00',
|
||||
reportRange: 'yesterday',
|
||||
target: '技术交流群',
|
||||
enabled: true,
|
||||
createdAt: new Date(fixtureNowMs - 172_800_000).toISOString(),
|
||||
updatedAt: new Date(fixtureNowMs - 172_800_000).toISOString(),
|
||||
nextRunAt: new Date(fixtureNowMs + 64_800_000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'fixture-scheduled-3',
|
||||
name: 'AI 编程讨论群日报',
|
||||
group: 'AI 编程讨论群',
|
||||
scheduleTime: '21:30',
|
||||
reportRange: 'recent24h',
|
||||
target: 'TechMemo 归档群',
|
||||
enabled: false,
|
||||
createdAt: new Date(fixtureNowMs - 259_200_000).toISOString(),
|
||||
updatedAt: new Date(fixtureNowMs - 259_200_000).toISOString(),
|
||||
nextRunAt: new Date(fixtureNowMs + 86_400_000).toISOString()
|
||||
}
|
||||
]
|
||||
: []
|
||||
const scheduledReportExecutions = []
|
||||
const generatedReports = []
|
||||
let scheduledReportNotificationEnabled = false
|
||||
|
||||
Reference in New Issue
Block a user