From 5837a2a6f2e38a365a99b92147a17f10adadbb57 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sat, 21 Feb 2026 15:57:07 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84AutoScoreManager=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B9=E4=BE=BF=E6=B7=BB=E5=8A=A0actions=E5=92=8Ctriggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/AutoScoreService.ts | 145 +++++-- .../src/components/AutoScoreManager.tsx | 368 ++++++------------ .../com.automatically/ActionItem.tsx | 52 +++ .../com.automatically/TriggerItem.tsx | 43 ++ .../actions/AddScoreAction.tsx | 29 ++ .../actions/AddTagAction.tsx | 21 + .../actions/SendNotificationAction.tsx | 21 + .../actions/SetStudentStatusAction.tsx | 28 ++ .../com.automatically/actions/index.ts | 70 ++++ .../src/components/com.automatically/index.ts | 17 + .../components/com.automatically/registry.ts | 66 ++++ .../triggers/IntervalTimeTrigger.tsx | 41 ++ .../triggers/RandomTimeTrigger.tsx | 77 ++++ .../triggers/StudentTagTrigger.tsx | 50 +++ .../com.automatically/triggers/index.ts | 55 +++ .../src/components/com.automatically/types.ts | 51 +++ src/renderer/src/services/AutoScoreService.ts | 144 +------ src/shared/triggers/IntervalTimeTrigger.ts | 52 +++ src/shared/triggers/RandomTimeTrigger.ts | 65 ++++ src/shared/triggers/StudentTagTrigger.ts | 30 ++ src/shared/triggers/index.ts | 17 + src/shared/triggers/types.ts | 32 ++ tsconfig.node.json | 2 +- 23 files changed, 1063 insertions(+), 413 deletions(-) create mode 100644 src/renderer/src/components/com.automatically/ActionItem.tsx create mode 100644 src/renderer/src/components/com.automatically/TriggerItem.tsx create mode 100644 src/renderer/src/components/com.automatically/actions/AddScoreAction.tsx create mode 100644 src/renderer/src/components/com.automatically/actions/AddTagAction.tsx create mode 100644 src/renderer/src/components/com.automatically/actions/SendNotificationAction.tsx create mode 100644 src/renderer/src/components/com.automatically/actions/SetStudentStatusAction.tsx create mode 100644 src/renderer/src/components/com.automatically/actions/index.ts create mode 100644 src/renderer/src/components/com.automatically/index.ts create mode 100644 src/renderer/src/components/com.automatically/registry.ts create mode 100644 src/renderer/src/components/com.automatically/triggers/IntervalTimeTrigger.tsx create mode 100644 src/renderer/src/components/com.automatically/triggers/RandomTimeTrigger.tsx create mode 100644 src/renderer/src/components/com.automatically/triggers/StudentTagTrigger.tsx create mode 100644 src/renderer/src/components/com.automatically/triggers/index.ts create mode 100644 src/renderer/src/components/com.automatically/types.ts create mode 100644 src/shared/triggers/IntervalTimeTrigger.ts create mode 100644 src/shared/triggers/RandomTimeTrigger.ts create mode 100644 src/shared/triggers/StudentTagTrigger.ts create mode 100644 src/shared/triggers/index.ts create mode 100644 src/shared/triggers/types.ts diff --git a/src/main/services/AutoScoreService.ts b/src/main/services/AutoScoreService.ts index 7392959..7d19ced 100644 --- a/src/main/services/AutoScoreService.ts +++ b/src/main/services/AutoScoreService.ts @@ -1,6 +1,7 @@ import { Service } from '../../shared/kernel' import { MainContext } from '../context' import { student } from '../repos/StudentRepository' +import { getTriggerLogic } from '../../shared/triggers' interface AutoScoreRule { id: number @@ -129,7 +130,6 @@ export class AutoScoreService extends Service { const data = await fs.readJsonFile(RULES_FILE_NAME, 'automatic') if (data && data.rules) { this.rules = data.rules.map((rule: any) => { - // 数据迁移:将旧格式转换为新格式 const migratedRule = this.migrateRule(rule) return { ...migratedRule, @@ -138,7 +138,6 @@ export class AutoScoreService extends Service { : undefined } }) - // 如果有数据迁移,保存新格式 if ( data.rules.some( (rule: any) => rule.intervalMinutes !== undefined || rule.scoreValue !== undefined @@ -159,12 +158,10 @@ export class AutoScoreService extends Service { } private migrateRule(rule: any): AutoScoreRule { - // 如果已经是新格式,直接返回 if (!rule.intervalMinutes && !rule.scoreValue) { return rule } - // 迁移旧格式到新格式 const migratedRule: AutoScoreRule = { id: rule.id, enabled: rule.enabled, @@ -175,7 +172,6 @@ export class AutoScoreService extends Service { actions: rule.actions || [] } - // 将intervalMinutes迁移到triggers if ( rule.intervalMinutes && !migratedRule.triggers?.find((t) => t.event === 'interval_time_passed') @@ -187,7 +183,6 @@ export class AutoScoreService extends Service { }) } - // 将scoreValue和reason迁移到actions if ( rule.scoreValue !== undefined && !migratedRule.actions?.find((a) => a.event === 'add_score') @@ -280,25 +275,28 @@ export class AutoScoreService extends Service { this.timers.delete(rule.id) } - // 从triggers中读取间隔时间 - const intervalTrigger = rule.triggers?.find((t) => t.event === 'interval_time_passed') - const intervalMinutes = intervalTrigger?.value ? parseInt(intervalTrigger.value, 10) : 0 + const now = new Date() + let delayMs = 0 + let primaryTrigger: { event: string; value?: string } | undefined - if (!intervalMinutes || intervalMinutes <= 0) { - this.logger.warn(`Rule ${rule.name} has no valid interval time, skipping timer`) + for (const trigger of rule.triggers || []) { + const logic = getTriggerLogic(trigger.event) + if (logic?.calculateNextTime) { + const result = logic.calculateNextTime(trigger.value || '', rule.lastExecuted, now) + if (delayMs === 0 || result.delayMs < delayMs) { + delayMs = result.delayMs + primaryTrigger = trigger + } + } + } + + if (!primaryTrigger) { + this.logger.warn(`Rule ${rule.name} has no valid triggers with timing logic, skipping`) return } - const now = new Date() - const intervalMs = intervalMinutes * 60 * 1000 - - let delayMs = intervalMs - if (rule.lastExecuted) { - const timeSinceLastExecution = now.getTime() - rule.lastExecuted.getTime() - delayMs = intervalMs - (timeSinceLastExecution % intervalMs) - if (timeSinceLastExecution >= intervalMs) { - delayMs = 0 - } + if (delayMs < 0) { + delayMs = 0 } const timer = setTimeout(() => { @@ -307,21 +305,32 @@ export class AutoScoreService extends Service { }, delayMs) this.timers.set(rule.id, timer) + this.logger.info(`Rule ${rule.name} scheduled to execute in ${delayMs}ms`) } private setRuleInterval(rule: AutoScoreRule) { - // 从triggers中读取间隔时间 - const intervalTrigger = rule.triggers?.find((t) => t.event === 'interval_time_passed') - const intervalMinutes = intervalTrigger?.value ? parseInt(intervalTrigger.value, 10) : 0 + const now = new Date() + let minDelayMs = Infinity + let primaryTrigger: { event: string; value?: string } | undefined - if (!intervalMinutes || intervalMinutes <= 0) { + for (const trigger of rule.triggers || []) { + const logic = getTriggerLogic(trigger.event) + if (logic?.calculateNextTime) { + const result = logic.calculateNextTime(trigger.value || '', rule.lastExecuted, now) + if (result.delayMs < minDelayMs) { + minDelayMs = result.delayMs + primaryTrigger = trigger + } + } + } + + if (!primaryTrigger || minDelayMs === Infinity) { return } - const intervalMs = intervalMinutes * 60 * 1000 const timer = setInterval(() => { this.executeRule(rule) - }, intervalMs) + }, minDelayMs) this.timers.set(rule.id, timer) } @@ -331,7 +340,6 @@ export class AutoScoreService extends Service { this.logger.info(`Executing auto score rule: ${rule.name}`) const studentRepo = this.mainCtx.students - const eventRepo = this.mainCtx.events let studentsToScore: student[] = [] if (rule.studentNames.length === 0) { @@ -347,17 +355,30 @@ export class AutoScoreService extends Service { } } - // 从actions中读取分数和理由 - const scoreAction = rule.actions?.find((a) => a.event === 'add_score') - const scoreValue = scoreAction?.value ? parseInt(scoreAction.value, 10) : 0 - const reason = scoreAction?.reason || `自动化加分 - ${rule.name}` + for (const trigger of rule.triggers || []) { + const logic = getTriggerLogic(trigger.event) + if (logic?.check) { + const context = { + students: studentsToScore, + events: [], + rule: { + id: rule.id, + name: rule.name, + studentNames: rule.studentNames, + triggers: rule.triggers, + actions: rule.actions + }, + now: new Date() + } + const result = logic.check(context, trigger.value || '') + if (result.matchedStudents && result.matchedStudents.length > 0) { + studentsToScore = result.matchedStudents + } + } + } - for (const student of studentsToScore) { - await eventRepo.create({ - student_name: student.name, - reason_content: reason, - delta: scoreValue - }) + for (const action of rule.actions || []) { + await this.executeAction(action, studentsToScore, rule.name) } rule.lastExecuted = new Date() @@ -371,6 +392,54 @@ export class AutoScoreService extends Service { } } + private async executeAction( + action: { event: string; value?: string; reason?: string }, + students: student[], + ruleName: string + ) { + const eventRepo = this.mainCtx.events + + switch (action.event) { + case 'add_score': { + const scoreValue = action.value ? parseInt(action.value, 10) : 0 + const reason = action.reason || `自动化加分 - ${ruleName}` + for (const student of students) { + await eventRepo.create({ + student_name: student.name, + reason_content: reason, + delta: scoreValue + }) + } + break + } + case 'add_tag': { + const tagName = action.value + if (tagName) { + const studentRepo = this.mainCtx.students + for (const student of students) { + const currentTags = student.tags || [] + if (!currentTags.includes(tagName)) { + await studentRepo.update(student.id, { + tags: [...currentTags, tagName] + }) + } + } + } + break + } + case 'send_notification': { + this.logger.info(`Notification action: ${action.value}`) + break + } + case 'set_student_status': { + this.logger.info(`Set student status action: ${action.value} (not implemented - student type has no status field)`) + break + } + default: + this.logger.warn(`Unknown action event: ${action.event}`) + } + } + private stopRules() { for (const [timer] of this.timers) { clearTimeout(timer) diff --git a/src/renderer/src/components/AutoScoreManager.tsx b/src/renderer/src/components/AutoScoreManager.tsx index d36f1be..2d6e14d 100644 --- a/src/renderer/src/components/AutoScoreManager.tsx +++ b/src/renderer/src/components/AutoScoreManager.tsx @@ -1,6 +1,9 @@ import React, { useState, useEffect } from 'react' -import { AddIcon, Delete1Icon, MoveIcon } from 'tdesign-icons-react' -import { allTriggers, allActions, TriggerItem, ActionItem } from '../services/AutoScoreService' +import { AddIcon, MoveIcon } from 'tdesign-icons-react' +import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically' +import type { TriggerItem, ActionItem } from './com.automatically/types' +import TriggerItemComponent from './com.automatically/TriggerItem' +import ActionItemComponent from './com.automatically/ActionItem' import { Card, Form, @@ -12,12 +15,11 @@ import { Space, Switch, Popconfirm, - Radio, Select, TooltipLite } from 'tdesign-react' +import Code from './Code' -import Code from './Code'; interface AutoScoreRule { id: number enabled: boolean @@ -41,6 +43,8 @@ export const AutoScoreManager: React.FC = () => { const [pageSize, setPageSize] = useState(50) const [form] = Form.useForm() const [editingRuleId, setEditingRuleId] = useState(null) + const [triggerList, setTriggerList] = useState([]) + const [actionList, setActionList] = useState([]) const fetchRules = async () => { if (!(window as any).api) return @@ -92,35 +96,16 @@ export const AutoScoreManager: React.FC = () => { return } - // 验证触发器必填项 if (triggerList.length === 0) { MessagePlugin.warning('请至少添加一个触发器') return } - for (const t of triggerList) { - const def = allTriggers.find((a) => a.eventName === t.eventName) - if (def?.valueType && (!t.value || String(t.value).trim() === '')) { - MessagePlugin.warning(`触发器 ${def.label} 需要填写 value`) - return - } - } - - // 验证行动必填项 if (actionList.length === 0) { MessagePlugin.warning('请至少添加一个行动') return } - for (const a of actionList) { - const def = allActions.find((action) => action.eventName === a.eventName) - if (def?.valueType && (!a.value || String(a.value).trim() === '')) { - MessagePlugin.warning(`行动 ${def.label} 需要填写 value`) - return - } - } - - // 确保 studentNames 是数组类型 const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] const triggersPayload = triggerList.map((t) => ({ event: t.eventName, value: t.value })) @@ -138,7 +123,6 @@ export const AutoScoreManager: React.FC = () => { actions: actionsPayload } - // 权限检查:仅管理员可创建/更新自动化 try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { @@ -152,19 +136,16 @@ export const AutoScoreManager: React.FC = () => { try { let res if (editingRuleId !== null) { - // 更新现有自动化 res = await (window as any).api.invoke('auto-score:updateRule', { id: editingRuleId, ...ruleData }) } else { - // 创建新自动化 res = await (window as any).api.invoke('auto-score:addRule', ruleData) } if (res.success) { MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功') - // 手动清空表单字段,避免 form.reset() 导致的栈溢出 form.setFieldsValue({ name: '', studentNames: '' @@ -172,7 +153,7 @@ export const AutoScoreManager: React.FC = () => { setEditingRuleId(null) setTriggerList([]) setActionList([]) - fetchRules() // 刷新自动化列表 + fetchRules() } else { MessagePlugin.error( res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败') @@ -183,39 +164,30 @@ export const AutoScoreManager: React.FC = () => { MessagePlugin.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败') } } + const handleEdit = (rule: AutoScoreRule) => { setEditingRuleId(rule.id) form.setFieldsValue({ name: rule.name, studentNames: rule.studentNames.join(', ') }) - // 如果后端返回了 triggers 字段,把它加载到 triggerList if (rule.triggers && Array.isArray(rule.triggers)) { - const mapped = rule.triggers.map((t, idx) => { - const found = allTriggers.find((a) => a.eventName === t.event) - return { - id: idx + 1, - eventName: t.event, - haveValue: !!found?.valueType, - value: t.value ?? '' - } - }) + const mapped = rule.triggers.map((t, idx) => ({ + id: idx + 1, + eventName: t.event, + value: t.value ?? '' + })) setTriggerList(mapped) } else { setTriggerList([]) } - // 如果后端返回了 actions 字段,把它加载到 actionList if (rule.actions && Array.isArray(rule.actions)) { - const mapped = rule.actions.map((a, idx) => { - const found = allActions.find((action) => action.eventName === a.event) - return { - id: idx + 1, - eventName: a.event, - valueType: found?.valueType, - value: a.value ?? '', - reason: a.reason - } - }) + const mapped = rule.actions.map((a, idx) => ({ + id: idx + 1, + eventName: a.event, + value: a.value ?? '', + reason: a.reason ?? '' + })) setActionList(mapped) } else { setActionList([]) @@ -224,7 +196,6 @@ export const AutoScoreManager: React.FC = () => { const handleDelete = async (ruleId: number) => { if (!(window as any).api) return - // 权限检查 try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { @@ -239,7 +210,7 @@ export const AutoScoreManager: React.FC = () => { const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId) if (res.success) { MessagePlugin.success('自动化删除成功') - fetchRules() // 刷新自动化列表 + fetchRules() } else { MessagePlugin.error(res.message || '删除自动化失败') } @@ -251,7 +222,6 @@ export const AutoScoreManager: React.FC = () => { const handleToggle = async (ruleId: number, enabled: boolean) => { if (!(window as any).api) return - // 权限检查 try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { @@ -266,7 +236,7 @@ export const AutoScoreManager: React.FC = () => { const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled }) if (res.success) { MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用') - fetchRules() // 刷新自动化列表 + fetchRules() } else { MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败')) } @@ -277,7 +247,6 @@ export const AutoScoreManager: React.FC = () => { } const handleResetForm = () => { - // 手动清空表单字段,避免 form.reset() 导致的栈溢出 form.setFieldsValue({ name: '', studentNames: '' @@ -287,6 +256,73 @@ export const AutoScoreManager: React.FC = () => { setActionList([]) } + const handleAddTrigger = () => { + const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1 + const defaultTrigger = allTriggers.list[0] + if (!defaultTrigger) { + MessagePlugin.error('没有可用的触发器类型,请检查配置') + return + } + setTriggerList((prev) => [ + ...prev, + { + id: nextId, + eventName: defaultTrigger.eventName, + value: '' + } + ]) + } + + const handleDeleteTrigger = (id: number) => { + setTriggerList((prev) => prev.filter((t) => t.id !== id)) + } + + const handleTriggerChange = (id: number, eventName: string) => { + setTriggerList((prev) => + prev.map((t) => (t.id === id ? { ...t, eventName, value: '' } : t)) + ) + } + + const handleTriggerValueChange = (id: number, value: string) => { + setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, value } : t))) + } + + const handleAddAction = () => { + const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1 + const defaultAction = allActions.list[0] + if (!defaultAction) { + MessagePlugin.error('没有可用的行动类型,请检查配置') + return + } + setActionList((prev) => [ + ...prev, + { + id: nextId, + eventName: defaultAction.eventName, + value: '', + reason: '' + } + ]) + } + + const handleDeleteAction = (id: number) => { + setActionList((prev) => prev.filter((a) => a.id !== id)) + } + + const handleActionChange = (id: number, eventName: string) => { + setActionList((prev) => + prev.map((a) => (a.id === id ? { ...a, eventName, value: '' } : a)) + ) + } + + const handleActionValueChange = (id: number, value: string) => { + setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, value } : a))) + } + + const handleActionReasonChange = (id: number, reason: string) => { + setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a))) + } + const columns: PrimaryTableCol[] = [ { colKey: 'drag', @@ -316,7 +352,7 @@ export const AutoScoreManager: React.FC = () => { return } const triggerLabels = row.triggers.map((t) => { - const def = allTriggers.find((tr) => tr.eventName === t.event) + const def = triggerRegistry.get(t.event) return def?.label || t.event }) return ( @@ -340,7 +376,7 @@ export const AutoScoreManager: React.FC = () => { return } const actionLabels = row.actions.map((a) => { - const def = allActions.find((ac) => ac.eventName === a.event) + const def = actionRegistry.get(a.event) return def?.label || a.event }) return ( @@ -403,180 +439,34 @@ export const AutoScoreManager: React.FC = () => { ) } ] + const onDragSort = (params: any) => setRules(params.newData) - const triggerOptions = allTriggers.map((t) => ({ label: t.label, value: t.eventName })) - - const [triggerList, setTriggerList] = useState([]) - const [actionList, setActionList] = useState([]) - - const handleTriggerChange = (id: number, value: string) => { - const found = allTriggers.find((a) => a.eventName === value) - setTriggerList((prev) => - prev.map((t) => - t.id === id ? { ...t, eventName: value, valueType: found?.valueType, value: '' } : t - ) - ) - } - - const handleValueChange = (id: number, val: string) => { - setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, value: val } : t))) - } - - const handleDeleteTrigger = (id: number) => { - setTriggerList((prev) => prev.filter((t) => t.id !== id)) - } - - const handleAddTrigger = () => { - const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1 - const defaultTrigger = allTriggers[0] - setTriggerList((prev) => [ - ...prev, - { - id: nextId, - eventName: defaultTrigger.eventName, - valueType: defaultTrigger.valueType, - value: '' - } - ]) - } - - // 行动管理相关函数 - const handleActionChange = (id: number, value: string) => { - const found = allActions.find((a) => a.eventName === value) - setActionList((prev) => - prev.map((t) => - t.id === id ? { ...t, eventName: value, valueType: found?.valueType, value: '' } : t - ) - ) - } - - const handleActionValueChange = (id: number, val: string) => { - setActionList((prev) => prev.map((t) => (t.id === id ? { ...t, value: val } : t))) - } - - const handleDeleteAction = (id: number) => { - setActionList((prev) => prev.filter((t) => t.id !== id)) - } - - const handleAddAction = () => { - const nextId = actionList.length ? Math.max(...actionList.map((t) => t.id)) + 1 : 1 - const defaultAction = allActions[0] - setActionList((prev) => [ - ...prev, - { - id: nextId, - eventName: defaultAction.eventName, - valueType: defaultAction.valueType, - value: '' - } - ]) - } - const triggerItems = triggerList .filter((t) => t.eventName !== null) - .map((triggerTest) => ( -
-
+ .map((item) => ( + )) + return (

自动化加分管理

@@ -670,19 +560,18 @@ export const AutoScoreManager: React.FC = () => { - { + { if (editingRuleId !== null) { - // 显示当前编辑的规则 - const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues; - const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []; - const triggersPayload = triggerList.map((t) => ({ event: t.eventName, value: t.value })); + const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues + const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] + const triggersPayload = triggerList.map((t) => ({ event: t.eventName, value: t.value })) const actionsPayload = actionList.map((a) => ({ event: a.eventName, value: a.value, reason: a.reason - })); - + })) + const currentRule = { id: editingRuleId, enabled: true, @@ -690,25 +579,16 @@ export const AutoScoreManager: React.FC = () => { studentNames, triggers: triggersPayload, actions: actionsPayload - }; - - return JSON.stringify(currentRule, null, 2); + } + + return JSON.stringify(currentRule, null, 2) } else { - // 显示所有规则 - return JSON.stringify(rules, null, 2); + return JSON.stringify(rules, null, 2) } })()} - language={'json'}/> + language={'json'} + /> - {/*
-

使用说明

-
    -
  • 自动化加分功能会按照设定的时间间隔自动为学生加分
  • -
  • 间隔时间以分钟为单位,例如1440表示每24小时(一天)执行一次
  • -
  • 如果"适用学生"字段为空,则自动化适用于所有学生
  • -
  • 可以随时启用/禁用自动化,不会影响已保存的自动化配置
  • -
-
*/}
) } diff --git a/src/renderer/src/components/com.automatically/ActionItem.tsx b/src/renderer/src/components/com.automatically/ActionItem.tsx new file mode 100644 index 0000000..83d3d35 --- /dev/null +++ b/src/renderer/src/components/com.automatically/ActionItem.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { Button, Select } from 'tdesign-react' +import { Delete1Icon } from 'tdesign-icons-react' +import { actionRegistry, allActions } from './registry' +import type { ActionItem as ActionItemType } from './types' + +interface ActionItemProps { + item: ActionItemType + onDelete: (id: number) => void + onChange: (id: number, eventName: string) => void + onValueChange: (id: number, value: string) => void + onReasonChange: (id: number, reason: string) => void +} + +const ActionItem: React.FC = ({ + item, + onDelete, + onChange, + onValueChange, + onReasonChange +}) => { + const definition = actionRegistry.get(item.eventName) + const Component = definition?.component + + return ( +
+
+ ) +} + +export default TriggerItem diff --git a/src/renderer/src/components/com.automatically/actions/AddScoreAction.tsx b/src/renderer/src/components/com.automatically/actions/AddScoreAction.tsx new file mode 100644 index 0000000..62f1631 --- /dev/null +++ b/src/renderer/src/components/com.automatically/actions/AddScoreAction.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Input } from 'tdesign-react' +import type { ActionComponentProps } from '../types' + +export const eventName = 'add_score' +export const label = '添加分数' +export const description = '为学生添加分数' +export const hasReason = true + +const AddScoreAction: React.FC = ({ value, reason, onChange, onReasonChange }) => { + return ( + <> + onChange(v ? String(v) : '')} + /> + onReasonChange?.(v ? String(v) : '')} + /> + + ) +} + +export default AddScoreAction diff --git a/src/renderer/src/components/com.automatically/actions/AddTagAction.tsx b/src/renderer/src/components/com.automatically/actions/AddTagAction.tsx new file mode 100644 index 0000000..dd6afb7 --- /dev/null +++ b/src/renderer/src/components/com.automatically/actions/AddTagAction.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { Input } from 'tdesign-react' +import type { ActionComponentProps } from '../types' + +export const eventName = 'add_tag' +export const label = '添加标签' +export const description = '为学生添加标签' +export const hasReason = false + +const AddTagAction: React.FC = ({ value, onChange }) => { + return ( + onChange(v ? String(v) : '')} + /> + ) +} + +export default AddTagAction diff --git a/src/renderer/src/components/com.automatically/actions/SendNotificationAction.tsx b/src/renderer/src/components/com.automatically/actions/SendNotificationAction.tsx new file mode 100644 index 0000000..326a501 --- /dev/null +++ b/src/renderer/src/components/com.automatically/actions/SendNotificationAction.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { Input } from 'tdesign-react' +import type { ActionComponentProps } from '../types' + +export const eventName = 'send_notification' +export const label = '发送通知' +export const description = '向学生发送通知' +export const hasReason = false + +const SendNotificationAction: React.FC = ({ value, onChange }) => { + return ( + onChange(v ? String(v) : '')} + /> + ) +} + +export default SendNotificationAction diff --git a/src/renderer/src/components/com.automatically/actions/SetStudentStatusAction.tsx b/src/renderer/src/components/com.automatically/actions/SetStudentStatusAction.tsx new file mode 100644 index 0000000..312cb00 --- /dev/null +++ b/src/renderer/src/components/com.automatically/actions/SetStudentStatusAction.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { Select } from 'tdesign-react' +import type { ActionComponentProps } from '../types' + +export const eventName = 'set_student_status' +export const label = '设置学生状态' +export const description = '设置学生的状态' +export const hasReason = false + +const statusOptions = [ + { label: '活跃', value: 'active' }, + { label: '不活跃', value: 'inactive' }, + { label: '请假', value: 'leave' } +] + +const SetStudentStatusAction: React.FC = ({ value, onChange }) => { + return ( + onChange(v ? String(v) : '')} + options={tags} + filterable + clearable + /> + ) +} + +export default StudentTagTrigger diff --git a/src/renderer/src/components/com.automatically/triggers/index.ts b/src/renderer/src/components/com.automatically/triggers/index.ts new file mode 100644 index 0000000..6251240 --- /dev/null +++ b/src/renderer/src/components/com.automatically/triggers/index.ts @@ -0,0 +1,55 @@ +import { triggerRegistry } from '../registry' +import type { TriggerDefinition } from '../types' + +import IntervalTimeTrigger, { + eventName as intervalEventName, + label as intervalLabel, + description as intervalDescription, + triggerLogic as intervalTriggerLogic +} from './IntervalTimeTrigger' + +import StudentTagTrigger, { + eventName as studentTagEventName, + label as studentTagLabel, + description as studentTagDescription, + triggerLogic as studentTagTriggerLogic +} from './StudentTagTrigger' + +import RandomTimeTrigger, { + eventName as randomTimeEventName, + label as randomTimeLabel, + description as randomTimeDescription, + triggerLogic as randomTimeTriggerLogic +} from './RandomTimeTrigger' + +const triggerDefinitions: TriggerDefinition[] = [ + { + eventName: intervalEventName, + label: intervalLabel, + description: intervalDescription, + component: IntervalTimeTrigger, + triggerLogic: intervalTriggerLogic + }, + { + eventName: studentTagEventName, + label: studentTagLabel, + description: studentTagDescription, + component: StudentTagTrigger, + triggerLogic: studentTagTriggerLogic + }, + { + eventName: randomTimeEventName, + label: randomTimeLabel, + description: randomTimeDescription, + component: RandomTimeTrigger, + triggerLogic: randomTimeTriggerLogic + } +] + +triggerDefinitions.forEach((def) => triggerRegistry.register(def)) + +export { + IntervalTimeTrigger, + StudentTagTrigger, + RandomTimeTrigger +} diff --git a/src/renderer/src/components/com.automatically/types.ts b/src/renderer/src/components/com.automatically/types.ts new file mode 100644 index 0000000..47ccbb8 --- /dev/null +++ b/src/renderer/src/components/com.automatically/types.ts @@ -0,0 +1,51 @@ +import React from 'react' + +export interface TriggerComponentProps { + value: string + onChange: (value: string) => void +} + +export interface ActionComponentProps { + value: string + reason: string + onChange: (value: string) => void + onReasonChange: (reason: string) => void +} + +export interface TriggerLogic { + eventName: string + label: string + description: string + validate: (value: string) => { valid: boolean; message?: string } + calculateNextTime?: (value: string, lastExecuted: Date | undefined, now: Date) => { delayMs: number; nextExecuteTime: Date } + check?: (context: any, value: string) => { shouldExecute: boolean; message?: string } +} + +export interface TriggerDefinition { + eventName: string + label: string + description: string + component: React.FC + triggerLogic?: TriggerLogic +} + +export interface ActionDefinition { + eventName: string + label: string + description: string + component: React.FC + hasReason?: boolean +} + +export interface TriggerItem { + id: number + eventName: string + value: string +} + +export interface ActionItem { + id: number + eventName: string + value: string + reason: string +} diff --git a/src/renderer/src/services/AutoScoreService.ts b/src/renderer/src/services/AutoScoreService.ts index b378c56..3b92c8a 100644 --- a/src/renderer/src/services/AutoScoreService.ts +++ b/src/renderer/src/services/AutoScoreService.ts @@ -1,7 +1,7 @@ -import React from 'react' import { Service } from '../../../shared/kernel' import { ClientContext } from '../ClientContext' -import { Input, Select } from 'tdesign-react' +import { allTriggers, allActions } from '../components/com.automatically' +import type { TriggerItem, ActionItem } from '../components/com.automatically/types' export interface AutoScoreRule { id: number @@ -27,123 +27,8 @@ declare module '../../../shared/kernel' { } } -type RenderConfig = { - component?: React.ElementType - props?: Record - render?: (props: any) => React.ReactNode -} - -type TriggerDef = { - id: number - label: string - description: string - eventName: string - valueType?: React.ElementType - renderConfig?: RenderConfig -} - -export const allTriggers: TriggerDef[] = [ - { - id: 1, - label: '根据间隔时间触发', - description: '当学生注册时触发自动化', - eventName: 'interval_time_passed', - valueType: Input - }, - { - id: 2, - label: '按照学生标签触发', - description: '当学生完成作业时触发自动化', - eventName: 'student_tag_matched', - valueType: Input - }, - { - id: 3, - label: '随机时间触发', - description: '当随机时间到达时触发自动化', - eventName: 'random_time_reached' - } -] - -// 触发器和行动项类型定义 -export type TriggerItem = { - id: number - eventName: string - value?: string - valueType?: React.ElementType -} - -export type ActionItem = { - id: number - eventName: string - value?: string - reason?: string - valueType?: React.ElementType -} - -export const allActions: TriggerDef[] = [ - { - id: 1, - label: '添加分数', - description: '为学生添加分数', - eventName: 'add_score', - valueType: Input, - renderConfig: { - component: Input, - props: { - placeholder: '请输入分数', - style: { width: '150px' } - } - } - }, - { - id: 2, - label: '添加标签', - description: '为学生添加标签', - eventName: 'add_tag', - valueType: Input, - renderConfig: { - component: Input, - props: { - placeholder: '请输入标签', - style: { width: '150px' } - } - } - }, - { - id: 3, - label: '发送通知', - description: '向学生发送通知', - eventName: 'send_notification', - valueType: Input, - renderConfig: { - component: Input, - props: { - placeholder: '请输入通知内容', - style: { width: '150px' } - } - } - }, - { - id: 4, - label: '设置学生状态', - description: '设置学生的状态', - eventName: 'set_student_status', - valueType: Select, - renderConfig: { - component: Select, - props: { - placeholder: '请选择状态', - style: { width: '150px' }, - options: [ - { label: '活跃', value: 'active' }, - { label: '不活跃', value: 'inactive' }, - { label: '请假', value: 'leave' } - ] - } - } - } -] +export { allTriggers, allActions } +export type { TriggerItem, ActionItem } export class AutoScoreService extends Service { constructor(ctx: ClientContext) { @@ -183,22 +68,19 @@ export class AutoScoreService extends Service { return await (window as any).api.invoke('auto-score:getStatus', {}) } - // 触发器管理相关函数 createTriggerItem(triggerList: TriggerItem[]): TriggerItem { const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1 - const defaultTrigger = allTriggers[0] + const defaultTrigger = allTriggers.list[0] return { id: nextId, eventName: defaultTrigger.eventName, - valueType: defaultTrigger.valueType, value: '' } } updateTriggerEvent(triggerList: TriggerItem[], id: number, eventName: string): TriggerItem[] { - const found = allTriggers.find((a) => a.eventName === eventName) return triggerList.map((t) => - t.id === id ? { ...t, eventName, valueType: found?.valueType, value: '' } : t + t.id === id ? { ...t, eventName, value: '' } : t ) } @@ -210,22 +92,20 @@ export class AutoScoreService extends Service { return triggerList.filter((t) => t.id !== id) } - // 行动管理相关函数 createActionItem(actionList: ActionItem[]): ActionItem { const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1 - const defaultAction = allActions[0] + const defaultAction = allActions.list[0] return { id: nextId, eventName: defaultAction.eventName, - valueType: defaultAction.valueType, - value: '' + value: '', + reason: '' } } updateActionEvent(actionList: ActionItem[], id: number, eventName: string): ActionItem[] { - const found = allActions.find((a) => a.eventName === eventName) return actionList.map((a) => - a.id === id ? { ...a, eventName, valueType: found?.valueType, value: '' } : a + a.id === id ? { ...a, eventName, value: '' } : a ) } @@ -233,6 +113,10 @@ export class AutoScoreService extends Service { return actionList.map((a) => (a.id === id ? { ...a, value } : a)) } + updateActionReason(actionList: ActionItem[], id: number, reason: string): ActionItem[] { + return actionList.map((a) => (a.id === id ? { ...a, reason } : a)) + } + deleteActionItem(actionList: ActionItem[], id: number): ActionItem[] { return actionList.filter((a) => a.id !== id) } diff --git a/src/shared/triggers/IntervalTimeTrigger.ts b/src/shared/triggers/IntervalTimeTrigger.ts new file mode 100644 index 0000000..afe927e --- /dev/null +++ b/src/shared/triggers/IntervalTimeTrigger.ts @@ -0,0 +1,52 @@ +import type { TriggerLogic } from './types' + +export const intervalTimeTrigger: TriggerLogic = { + eventName: 'interval_time_passed', + label: '根据间隔时间触发', + description: '当间隔时间到达时触发自动化', + + validate: (value: string) => { + const minutes = parseInt(value, 10) + if (isNaN(minutes) || minutes <= 0) { + return { valid: false, message: '请输入有效的时间间隔(分钟)' } + } + return { valid: true } + }, + + calculateNextTime: (value: string, lastExecuted: Date | undefined, now: Date) => { + const intervalMinutes = parseInt(value, 10) + if (isNaN(intervalMinutes) || intervalMinutes <= 0) { + return { delayMs: 0, nextExecuteTime: now } + } + + const intervalMs = intervalMinutes * 60 * 1000 + + let delayMs = intervalMs + if (lastExecuted) { + const timeSinceLastExecution = now.getTime() - lastExecuted.getTime() + delayMs = intervalMs - (timeSinceLastExecution % intervalMs) + if (timeSinceLastExecution >= intervalMs) { + delayMs = 0 + } + } + + const nextExecuteTime = new Date(now.getTime() + delayMs) + return { delayMs, nextExecuteTime } + }, + + check: (context, value) => { + const result = intervalTimeTrigger.calculateNextTime!( + value, + context.rule.triggers?.find((t) => t.event === 'interval_time_passed') + ? context.now + : undefined, + context.now + ) + return { + shouldExecute: result.delayMs === 0, + matchedStudents: context.students, + nextExecuteTime: result.nextExecuteTime, + delayMs: result.delayMs + } + } +} diff --git a/src/shared/triggers/RandomTimeTrigger.ts b/src/shared/triggers/RandomTimeTrigger.ts new file mode 100644 index 0000000..ddab590 --- /dev/null +++ b/src/shared/triggers/RandomTimeTrigger.ts @@ -0,0 +1,65 @@ +import type { TriggerLogic } from './types' + +export const randomTimeTrigger: TriggerLogic = { + eventName: 'random_time_reached', + label: '随机时间触发', + description: '当随机时间到达时触发自动化', + + validate: (value: string) => { + if (!value) { + return { valid: true } + } + try { + const config = JSON.parse(value) + if (config.minHour !== undefined || config.maxHour !== undefined) { + const minHour = config.minHour ?? 0 + const maxHour = config.maxHour ?? 23 + if (minHour < 0 || minHour > 23 || maxHour < 0 || maxHour > 23) { + return { valid: false, message: '小时范围必须在0-23之间' } + } + if (minHour > maxHour) { + return { valid: false, message: '最小小时不能大于最大小时' } + } + } + return { valid: true } + } catch { + return { valid: false, message: '配置格式错误' } + } + }, + + calculateNextTime: (value: string, _lastExecuted: Date | undefined, now: Date) => { + let config = { minHour: 9, maxHour: 18 } + try { + if (value) { + const parsed = JSON.parse(value) + config = { ...config, ...parsed } + } + } catch {} + + const minHour = config.minHour ?? 0 + const maxHour = config.maxHour ?? 23 + + const randomHour = Math.floor(Math.random() * (maxHour - minHour + 1)) + minHour + const randomMinute = Math.floor(Math.random() * 60) + + let targetDate = new Date(now) + targetDate.setHours(randomHour, randomMinute, 0, 0) + + if (targetDate.getTime() <= now.getTime()) { + targetDate.setDate(targetDate.getDate() + 1) + } + + const delayMs = targetDate.getTime() - now.getTime() + return { delayMs, nextExecuteTime: targetDate } + }, + + check: (context, value) => { + const result = randomTimeTrigger.calculateNextTime!(value, undefined, context.now) + return { + shouldExecute: result.delayMs === 0, + matchedStudents: context.students, + nextExecuteTime: result.nextExecuteTime, + delayMs: result.delayMs + } + } +} diff --git a/src/shared/triggers/StudentTagTrigger.ts b/src/shared/triggers/StudentTagTrigger.ts new file mode 100644 index 0000000..8cb503e --- /dev/null +++ b/src/shared/triggers/StudentTagTrigger.ts @@ -0,0 +1,30 @@ +import type { TriggerLogic } from './types' + +export const studentTagTrigger: TriggerLogic = { + eventName: 'student_tag_matched', + label: '按照学生标签触发', + description: '当学生标签匹配时触发自动化', + + validate: (value: string) => { + if (!value || value.trim() === '') { + return { valid: false, message: '请输入标签名称' } + } + return { valid: true } + }, + + check: (context, value) => { + const tagName = value.trim().toLowerCase() + const matchedStudents = context.students.filter((student: any) => { + if (student.tags && Array.isArray(student.tags)) { + return student.tags.some((tag: string) => tag.toLowerCase() === tagName) + } + return false + }) + + return { + shouldExecute: matchedStudents.length > 0, + matchedStudents, + nextExecuteTime: context.now + } + } +} diff --git a/src/shared/triggers/index.ts b/src/shared/triggers/index.ts new file mode 100644 index 0000000..52f5b2f --- /dev/null +++ b/src/shared/triggers/index.ts @@ -0,0 +1,17 @@ +import type { TriggerLogic } from './types' +import { intervalTimeTrigger } from './IntervalTimeTrigger' +import { studentTagTrigger } from './StudentTagTrigger' +import { randomTimeTrigger } from './RandomTimeTrigger' + +export * from './types' +export { intervalTimeTrigger, studentTagTrigger, randomTimeTrigger } + +export const allTriggerLogics: TriggerLogic[] = [ + intervalTimeTrigger, + studentTagTrigger, + randomTimeTrigger +] + +export function getTriggerLogic(eventName: string): TriggerLogic | undefined { + return allTriggerLogics.find((t) => t.eventName === eventName) +} diff --git a/src/shared/triggers/types.ts b/src/shared/triggers/types.ts new file mode 100644 index 0000000..9cd41c8 --- /dev/null +++ b/src/shared/triggers/types.ts @@ -0,0 +1,32 @@ +export interface TriggerContext { + students: any[] + events: any[] + rule: { + id: number + name: string + studentNames: string[] + triggers?: { event: string; value?: string }[] + actions?: { event: string; value?: string; reason?: string }[] + } + now: Date +} + +export interface TriggerResult { + shouldExecute: boolean + matchedStudents?: any[] + nextExecuteTime?: Date + delayMs?: number +} + +export interface TriggerLogic { + eventName: string + label: string + description: string + validate: (value: string) => { valid: boolean; message?: string } + calculateNextTime?: ( + value: string, + lastExecuted: Date | undefined, + now: Date + ) => { delayMs: number; nextExecuteTime: Date } + check?: (context: TriggerContext, value: string) => TriggerResult +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 81d8aec..792885f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,6 +1,6 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"], "compilerOptions": { "composite": true, "types": ["electron-vite/node"],