FEFACTARY:自动化加分beta

This commit is contained in:
NanGua-QWQ
2026-03-07 20:00:34 +08:00
parent 7b5fd3a067
commit 07d96ff5b5
33 changed files with 1373 additions and 1262 deletions
+28 -206
View File
@@ -1,9 +1,4 @@
import { useState, useEffect } from 'react'
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
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,
@@ -20,6 +15,9 @@ import {
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { RuleComponent } from './autoScore/ruleComponent'
import type { AutoScoreRuleData } from './autoScore/ruleBuilderUtils'
interface AutoScoreRule {
id: number
enabled: boolean
@@ -43,8 +41,10 @@ export const AutoScoreManager: React.FC = () => {
const [pageSize, setPageSize] = useState<number>(50)
const [form] = Form.useForm()
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [triggerList, setTriggerList] = useState<TriggerItem[]>([])
const [actionList, setActionList] = useState<ActionItem[]>([])
const [ruleData, setRuleData] = useState<AutoScoreRuleData>({
triggers: [],
actions: []
})
const [messageApi, contextHolder] = message.useMessage()
const fetchRules = async () => {
@@ -97,35 +97,24 @@ export const AutoScoreManager: React.FC = () => {
return
}
if (triggerList.length === 0) {
if (ruleData.triggers.length === 0) {
messageApi.warning('请至少添加一个触发器')
return
}
if (actionList.length === 0) {
if (ruleData.actions.length === 0) {
messageApi.warning('请至少添加一个行动')
return
}
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
const triggersPayload = triggerList.map((t) => ({
event: t.eventName,
value: t.value,
relation: t.relation
}))
const actionsPayload = actionList.map((a) => ({
event: a.eventName,
value: a.value,
reason: a.reason
}))
const ruleData = {
const ruleDataToSubmit = {
enabled: true,
name: values.name,
studentNames,
triggers: triggersPayload,
actions: actionsPayload
triggers: ruleData.triggers,
actions: ruleData.actions
}
try {
@@ -139,14 +128,14 @@ export const AutoScoreManager: React.FC = () => {
}
try {
let res
let res: { success: boolean; message?: string; data?: any }
if (editingRuleId !== null) {
res = await (window as any).api.invoke('auto-score:updateRule', {
id: editingRuleId,
...ruleData
...ruleDataToSubmit
})
} else {
res = await (window as any).api.invoke('auto-score:addRule', ruleData)
res = await (window as any).api.invoke('auto-score:addRule', ruleDataToSubmit)
}
if (res.success) {
@@ -156,8 +145,7 @@ export const AutoScoreManager: React.FC = () => {
studentNames: ''
})
setEditingRuleId(null)
setTriggerList([])
setActionList([])
setRuleData({ triggers: [], actions: [] })
fetchRules()
} else {
messageApi.error(
@@ -174,30 +162,12 @@ export const AutoScoreManager: React.FC = () => {
setEditingRuleId(rule.id)
form.setFieldsValue({
name: rule.name,
studentNames: rule.studentNames.join(', ')
studentNames: rule.studentNames
})
setRuleData({
triggers: rule.triggers || [],
actions: rule.actions || []
})
if (rule.triggers && Array.isArray(rule.triggers)) {
const mapped = rule.triggers.map((t, idx) => ({
id: idx + 1,
eventName: t.event,
value: t.value ?? '',
relation: t.relation ?? 'AND'
}))
setTriggerList(mapped)
} else {
setTriggerList([])
}
if (rule.actions && Array.isArray(rule.actions)) {
const mapped = rule.actions.map((a, idx) => ({
id: idx + 1,
eventName: a.event,
value: a.value ?? '',
reason: a.reason ?? ''
}))
setActionList(mapped)
} else {
setActionList([])
}
}
const handleDelete = async (ruleId: number) => {
@@ -258,85 +228,10 @@ export const AutoScoreManager: React.FC = () => {
studentNames: ''
})
setEditingRuleId(null)
setTriggerList([])
setActionList([])
}
const handleAddTrigger = () => {
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
const defaultTrigger = allTriggers.list[0]
if (!defaultTrigger) {
messageApi.error('没有可用的触发器类型,请检查配置')
return
}
setTriggerList((prev) => [
...prev,
{
id: nextId,
eventName: defaultTrigger.eventName,
value: '',
relation: 'AND'
}
])
}
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 handleTriggerRelationChange = (id: number, relation: 'AND' | 'OR') => {
setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, relation } : t)))
}
const handleAddAction = () => {
const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
const defaultAction = allActions.list[0]
if (!defaultAction) {
messageApi.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)))
setRuleData({ triggers: [], actions: [] })
}
const columns: ColumnsType<AutoScoreRule> = [
{
key: 'drag',
title: '排序',
width: 60,
render: () => <HolderOutlined style={{ cursor: 'move' }} />
},
{
title: '状态',
dataIndex: 'enabled',
@@ -356,15 +251,7 @@ export const AutoScoreManager: React.FC = () => {
if (!triggers || triggers.length === 0) {
return <span></span>
}
const triggerLabels = triggers.map((t) => {
const def = triggerRegistry.get(t.event)
return def?.label || t.event
})
return (
<Tooltip title={triggerLabels.join(', ')}>
<span>{triggers.length} </span>
</Tooltip>
)
return <span>{triggers.length} </span>
}
},
{
@@ -376,15 +263,7 @@ export const AutoScoreManager: React.FC = () => {
if (!actions || actions.length === 0) {
return <span></span>
}
const actionLabels = actions.map((a) => {
const def = actionRegistry.get(a.event)
return def?.label || a.event
})
return (
<Tooltip title={actionLabels.join(', ')}>
<span>{actions.length} </span>
</Tooltip>
)
return <span>{actions.length} </span>
}
},
{
@@ -438,33 +317,6 @@ export const AutoScoreManager: React.FC = () => {
}
]
const triggerItems = triggerList
.filter((t) => t.eventName !== null)
.map((item, idx) => (
<TriggerItemComponent
key={item.id}
item={item}
onDelete={handleDeleteTrigger}
onChange={handleTriggerChange}
onValueChange={handleTriggerValueChange}
onRelationChange={handleTriggerRelationChange}
isFirst={idx === 0}
/>
))
const actionItems = actionList
.filter((a) => a.eventName !== null)
.map((item) => (
<ActionItemComponent
key={item.id}
item={item}
onDelete={handleDeleteAction}
onChange={handleActionChange}
onValueChange={handleActionValueChange}
onReasonChange={handleActionReasonChange}
/>
))
return (
<div style={{ padding: '24px' }}>
{contextHolder}
@@ -491,6 +343,10 @@ export const AutoScoreManager: React.FC = () => {
</Form.Item>
</div>
<div style={{ marginTop: '16px' }}>
<RuleComponent initialData={ruleData} onChange={(data) => setRuleData(data)} />
</div>
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
<Button type="primary" onClick={handleSubmit}>
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
@@ -502,40 +358,6 @@ export const AutoScoreManager: React.FC = () => {
</Form>
</Card>
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="当以下规则触发时"
>
<Space orientation="vertical" style={{ width: '100%' }}>
{triggerItems}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddTrigger}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
</Space>
</Card>
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="满足规则时触发的行动"
>
<Space orientation="vertical" style={{ width: '100%' }}>
{actionItems}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddAction}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
</Space>
</Card>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Table
dataSource={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
+1 -1
View File
@@ -227,7 +227,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
bodyStyle={{ padding: '12px' }}
>
{contextHolder}
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space size={4}>
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
@@ -0,0 +1,94 @@
import { Space, Input, InputNumber, Select, Button } from 'antd'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
import type { AutoScoreAction } from './ruleBuilderUtils'
interface ActionComponentProps {
actions: AutoScoreAction[]
onAdd: () => void
onRemove: (index: number) => void
onChange: (index: number, field: keyof AutoScoreAction, value: any) => void
}
export const ActionComponent: React.FC<ActionComponentProps> = ({
actions,
onAdd,
onRemove,
onChange
}) => {
const handleActionChange = (index: number, field: keyof AutoScoreAction, value: any) => {
onChange(index, field, value)
}
return (
<div>
<Space direction="vertical" style={{ width: '100%' }}>
{actions.map((action, index) => (
<div
key={index}
style={{
padding: '12px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
backgroundColor: '#fafafa'
}}
>
<Space style={{ width: '100%' }} wrap>
<Select
value={action.event}
onChange={(value) => handleActionChange(index, 'event', value)}
style={{ width: 150 }}
options={Object.entries({
add_score: { label: '加分', description: '为学生增加分数' },
add_tag: { label: '添加标签', description: '为学生添加标签' }
}).map(([key, val]) => ({
label: val.label,
value: key
}))}
/>
{action.event === 'add_score' && (
<InputNumber
value={action.value ? parseInt(action.value) : 0}
onChange={(value) => handleActionChange(index, 'value', String(value || 0))}
placeholder="分数"
min={-100}
max={100}
style={{ width: 120 }}
/>
)}
{action.event === 'add_tag' && (
<Input
value={action.value}
onChange={(e) => handleActionChange(index, 'value', e.target.value)}
placeholder="标签名称"
style={{ width: 200 }}
/>
)}
<Input
value={action.reason}
onChange={(e) => handleActionChange(index, 'reason', e.target.value)}
placeholder="操作说明(可选)"
style={{ flex: 1, minWidth: 200 }}
/>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => onRemove(index)}
/>
</Space>
</div>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={onAdd} block>
</Button>
</Space>
</div>
)
}
export default ActionComponent
@@ -0,0 +1,307 @@
.queryBuilder {
--querybuilder-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
--querybuilder-background: var(--ss-card-bg, #ffffff);
--querybuilder-color: var(--ss-text-main, rgba(0, 0, 0, 0.85));
--querybuilder-border-radius: 6px;
--querybuilder-border-color: #d9d9d9;
--querybuilder-disabled-color: rgba(0, 0, 0, 0.25);
font-family: var(--querybuilder-font-family);
font-size: 14px;
line-height: 1.5715;
color: var(--querybuilder-color);
background: var(--querybuilder-background);
padding: 0;
border-radius: var(--querybuilder-border-radius);
}
.queryBuilder .ruleGroup {
background: var(--querybuilder-background);
border: 1px solid var(--querybuilder-border-color);
border-radius: var(--querybuilder-border-radius);
padding: 8px;
margin: 0;
}
.queryBuilder .ruleGroup-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.queryBuilder .ruleGroup-body {
padding-left: 20px;
border-left: 2px solid var(--querybuilder-border-color);
margin-left: 0;
}
.queryBuilder .rule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 4px 8px;
margin: 0;
background: rgba(0, 0, 0, 0.02);
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.3s;
}
.queryBuilder .rule:hover {
background: rgba(0, 0, 0, 0.04);
border-color: var(--querybuilder-border-color);
}
.queryBuilder select,
.queryBuilder .ruleGroup-combinators select {
appearance: none;
background: var(--querybuilder-background);
border: 1px solid var(--querybuilder-border-color);
border-radius: var(--querybuilder-border-radius);
padding: 4px 28px 4px 11px;
height: 32px;
font-size: 14px;
color: var(--querybuilder-color);
cursor: pointer;
transition: all 0.3s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(0,0,0,0.25)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.queryBuilder select:hover,
.queryBuilder .ruleGroup-combinators select:hover {
border-color: #40a9ff;
}
.queryBuilder select:focus,
.queryBuilder .ruleGroup-combinators select:focus {
outline: none;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.queryBuilder input[type='text'],
.queryBuilder input[type='number'],
.queryBuilder input:not([type]) {
background: var(--querybuilder-background);
border: 1px solid var(--querybuilder-border-color);
border-radius: var(--querybuilder-border-radius);
padding: 4px 11px;
height: 32px;
font-size: 14px;
color: var(--querybuilder-color);
transition: all 0.3s;
min-width: 120px;
}
.queryBuilder input[type='text']:hover,
.queryBuilder input[type='number']:hover,
.queryBuilder input:not([type]):hover {
border-color: #40a9ff;
}
.queryBuilder input[type='text']:focus,
.queryBuilder input[type='number']:focus,
.queryBuilder input:not([type]):focus {
outline: none;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.queryBuilder input::placeholder {
color: #bfbfbf;
}
.queryBuilder button {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 400;
white-space: nowrap;
text-align: center;
background-image: none;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.3s;
height: 32px;
padding: 4px 15px;
font-size: 14px;
border-radius: var(--querybuilder-border-radius);
color: var(--querybuilder-color);
background: var(--querybuilder-background);
border-color: var(--querybuilder-border-color);
}
.queryBuilder button:hover {
color: #40a9ff;
border-color: #40a9ff;
}
.queryBuilder button:focus {
outline: none;
color: #40a9ff;
border-color: #40a9ff;
}
.queryBuilder button.ruleGroup-addRule,
.queryBuilder button.ruleGroup-addGroup {
color: #fff;
background: var(--ant-color-primary, #1677ff);
border-color: var(--ant-color-primary, #1677ff);
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045);
}
.queryBuilder button.ruleGroup-addRule:hover,
.queryBuilder button.ruleGroup-addGroup:hover {
color: #fff;
background: var(--ant-color-primary-hover, #4096ff);
border-color: var(--ant-color-primary-hover, #4096ff);
}
.queryBuilder button.ruleGroup-addRule:focus,
.queryBuilder button.ruleGroup-addGroup:focus {
color: #fff;
background: var(--ant-color-primary-hover, #4096ff);
border-color: var(--ant-color-primary-hover, #4096ff);
}
.queryBuilder button.rule-remove,
.queryBuilder button.ruleGroup-remove {
color: #ff4d4f;
border-color: #ff4d4f;
background: transparent;
}
.queryBuilder button.rule-remove:hover,
.queryBuilder button.ruleGroup-remove:hover {
color: #ff7875;
border-color: #ff7875;
}
.queryBuilder button.rule-remove:focus,
.queryBuilder button.ruleGroup-remove:focus {
color: #ff7875;
border-color: #ff7875;
}
.queryBuilder .ruleGroup-combinators {
display: inline-flex;
align-items: center;
gap: 8px;
}
.queryBuilder .betweenRules {
display: inline-flex;
align-items: center;
gap: 4px;
}
.queryBuilder .betweenRules .operator-joiner {
color: var(--querybuilder-color);
padding: 0 4px;
}
.queryBuilder .ruleGroup-notToggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
color: var(--querybuilder-color);
}
.queryBuilder .ruleGroup-notToggle input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #1890ff;
}
.queryBuilder .dragHandle {
cursor: move;
color: var(--querybuilder-disabled-color);
padding: 4px;
}
.queryBuilder .dragHandle:hover {
color: var(--querybuilder-color);
}
.queryBuilder .shiftActions {
display: inline-flex;
gap: 4px;
margin-left: 8px;
}
.queryBuilder .shiftActions button {
padding: 4px 8px;
min-width: auto;
}
.queryBuilder .valueEditor-caseSensitive,
.queryBuilder .valueEditor-caseSensitive input[type='checkbox'] {
cursor: pointer;
}
.queryBuilder .valueEditor-caseSensitive input[type='checkbox'] {
width: 14px;
height: 14px;
accent-color: #1890ff;
}
.queryBuilder .valueSource-dropdown {
min-width: 100px;
}
.queryBuilder:disabled,
.queryBuilder .disabled {
opacity: 0.65;
cursor: not-allowed;
pointer-events: none;
}
.queryBuilder .validation-error {
color: #ff4d4f;
font-size: 12px;
margin-top: 4px;
}
.queryBuilder .rule-with-error {
border-color: #ff4d4f !important;
}
.queryBuilder .rule-with-error input,
.queryBuilder .rule-with-error select {
border-color: #ff4d4f !important;
}
.queryBuilder .rule-with-error input:focus,
.queryBuilder .rule-with-error select:focus {
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2) !important;
}
@media (prefers-color-scheme: dark) {
.queryBuilder {
--querybuilder-border-color: #434343;
--querybuilder-disabled-color: rgba(255, 255, 255, 0.3);
}
.queryBuilder .rule {
background: rgba(255, 255, 255, 0.04);
}
.queryBuilder .rule:hover {
background: rgba(255, 255, 255, 0.08);
}
.queryBuilder select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(255,255,255,0.3)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
.queryBuilder input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
}
@@ -0,0 +1,109 @@
import type { RuleGroupType, Field, Operator } from 'react-querybuilder'
export interface AutoScoreTrigger {
event: string
value?: string
relation?: 'AND' | 'OR'
}
export interface AutoScoreAction {
event: string
value?: string
reason?: string
}
export interface AutoScoreRuleData {
triggers: AutoScoreTrigger[]
actions: AutoScoreAction[]
}
export const TRIGGER_TYPES = {
interval_time_passed: {
label: '间隔时间',
description: '每隔指定时间自动触发'
},
student_has_tag: {
label: '学生标签',
description: '当学生拥有指定标签时触发'
}
}
export const ACTION_TYPES = {
add_score: {
label: '加分',
description: '为学生增加分数'
},
add_tag: {
label: '添加标签',
description: '为学生添加标签'
}
}
export const fields: Field[] = [
{ name: 'interval_time_passed', label: '间隔时间(分钟)', placeholder: '例如:1440' },
{ name: 'student_has_tag', label: '学生标签', placeholder: '例如:优秀学生,班干部' }
]
export const operators: Operator[] = [
{ name: '=', label: '等于' },
{ name: 'contains', label: '包含' }
]
export const defaultQuery: RuleGroupType = {
combinator: 'and',
rules: [{ field: 'interval_time_passed', operator: '=', value: '1440' }]
}
export function queryToAutoScoreRule(query: RuleGroupType): AutoScoreRuleData {
const triggers: AutoScoreTrigger[] = []
const processRuleGroup = (group: RuleGroupType, relation: 'AND' | 'OR' = 'AND') => {
group.rules.forEach((rule, index) => {
if ('rules' in rule) {
processRuleGroup(rule, group.combinator === 'and' ? 'AND' : 'OR')
} else {
const trigger: AutoScoreTrigger = {
event: rule.field,
value: rule.value as string
}
if (index > 0) {
trigger.relation = relation
}
triggers.push(trigger)
}
})
}
processRuleGroup(query)
return {
triggers,
actions: []
}
}
export function autoScoreRuleToQuery(ruleData: AutoScoreRuleData): RuleGroupType {
const rules: any[] = []
let currentCombinator: 'and' | 'or' = 'and'
ruleData.triggers.forEach((trigger, index) => {
if (index === 0) {
currentCombinator = 'and'
} else if (trigger.relation === 'OR') {
currentCombinator = 'or'
}
rules.push({
field: trigger.event,
operator: '=',
value: trigger.value || ''
})
})
return {
combinator: currentCombinator,
rules: rules.length > 0 ? rules : defaultQuery.rules
}
}
@@ -0,0 +1,82 @@
import { formatQuery, QueryBuilder, RuleGroupType } from 'react-querybuilder'
import { useState, useEffect } from 'react'
import {
fields,
operators,
defaultQuery,
queryToAutoScoreRule,
autoScoreRuleToQuery,
type AutoScoreRuleData,
AutoScoreAction
} from './ruleBuilderUtils'
import { Card } from 'antd'
import './ruleBuilderOverride.css'
import { ActionComponent } from './actionComponent'
interface RuleComponentProps {
initialData?: AutoScoreRuleData
onChange?: (data: AutoScoreRuleData) => void
}
export const RuleComponent: React.FC<RuleComponentProps> = ({ initialData, onChange }) => {
const [query, setQuery] = useState<RuleGroupType>(
initialData ? autoScoreRuleToQuery(initialData) : defaultQuery
)
const [actions, setActions] = useState<AutoScoreRuleData['actions']>(initialData?.actions || [])
useEffect(() => {
if (onChange) {
const ruleData = queryToAutoScoreRule(query)
onChange({
...ruleData,
actions
})
}
}, [query, actions])
const handleQueryChange = (newQuery: RuleGroupType) => {
setQuery(newQuery)
}
const handleAddAction = () => {
setActions([...actions, { event: 'add_score', value: '5', reason: '' }])
}
const handleRemoveAction = (index: number) => {
setActions(actions.filter((_, i) => i !== index))
}
const handleActionChange = (index: number, field: keyof AutoScoreAction, value: any) => {
const newActions = [...actions]
newActions[index] = { ...newActions[index], [field]: value }
setActions(newActions)
}
return (
<div>
<Card title="触发条件" style={{ marginBottom: '16px' }}>
<QueryBuilder
fields={fields}
operators={operators}
query={query}
onQueryChange={handleQueryChange}
/>
<div style={{ marginTop: '8px' }}>
<pre style={{ fontSize: '12px', color: '#999' }}>{formatQuery(query, 'json')}</pre>
</div>
</Card>
<Card title="执行操作">
<ActionComponent
actions={actions}
onAdd={handleAddAction}
onRemove={handleRemoveAction}
onChange={handleActionChange}
/>
</Card>
</div>
)
}
export default RuleComponent
@@ -1,46 +0,0 @@
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
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<ActionItemProps> = ({
item,
onDelete,
onChange,
onValueChange,
onReasonChange
}) => {
const definition = actionRegistry.get(item.eventName)
const Component = definition?.component
return (
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px' }}
options={allActions.options}
placeholder="请选择触发行动"
onChange={(value) => onChange(item.id, value as string)}
/>
{Component && (
<Component
value={item.value}
reason={item.reason}
onChange={(value) => onValueChange(item.id, value)}
onReasonChange={(reason) => onReasonChange(item.id, reason)}
/>
)}
</div>
)
}
export default ActionItem
@@ -1,53 +0,0 @@
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { triggerRegistry, allTriggers } from './registry'
import type { TriggerItem as TriggerItemType } from './types'
interface TriggerItemProps {
item: TriggerItemType
onDelete: (id: number) => void
onChange: (id: number, eventName: string) => void
onValueChange: (id: number, value: string) => void
onRelationChange?: (id: number, relation: 'AND' | 'OR') => void
isFirst?: boolean
}
const TriggerItem: React.FC<TriggerItemProps> = ({
item,
onDelete,
onChange,
onValueChange,
onRelationChange,
isFirst = false
}) => {
const definition = triggerRegistry.get(item.eventName)
const Component = definition?.component
const relation = item.relation || 'AND'
return (
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
{!isFirst && (
<Button
type={relation === 'AND' ? 'primary' : 'default'}
danger={relation === 'OR'}
onClick={() => onRelationChange?.(item.id, relation === 'AND' ? 'OR' : 'AND')}
>
{relation === 'AND' ? '并' : '或'}
</Button>
)}
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px' }}
options={allTriggers.options}
placeholder="请选择触发规则"
onChange={(value) => onChange(item.id, value as string)}
/>
{Component && (
<Component value={item.value} onChange={(value) => onValueChange(item.id, value)} />
)}
</div>
)
}
export default TriggerItem
@@ -1,33 +0,0 @@
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_score'
export const label = '添加分数'
export const description = '为学生添加分数'
export const hasReason = true
const AddScoreAction: React.FC<ActionComponentProps> = ({
value,
reason,
onChange,
onReasonChange
}) => {
return (
<>
<Input
placeholder="请输入分数"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
<Input
placeholder="请输入理由"
style={{ width: '150px' }}
value={reason ?? ''}
onChange={(e) => onReasonChange?.(e.target.value ? String(e.target.value) : '')}
/>
</>
)
}
export default AddScoreAction
@@ -1,20 +0,0 @@
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_tag'
export const label = '添加标签'
export const description = '为学生添加标签'
export const hasReason = false
const AddTagAction: React.FC<ActionComponentProps> = ({ value, onChange }) => {
return (
<Input
placeholder="请输入标签"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
export default AddTagAction
@@ -1,20 +0,0 @@
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'send_notification'
export const label = '发送通知'
export const description = '向学生发送通知'
export const hasReason = false
const SendNotificationAction: React.FC<ActionComponentProps> = ({ value, onChange }) => {
return (
<Input
placeholder="请输入通知内容"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
export default SendNotificationAction
@@ -1,51 +0,0 @@
import { actionRegistry } from '../registry'
import type { ActionDefinition } from '../types'
import AddScoreAction, {
eventName as addScoreEventName,
label as addScoreLabel,
description as addScoreDescription,
hasReason as addScoreHasReason
} from './AddScoreAction'
import AddTagAction, {
eventName as addTagEventName,
label as addTagLabel,
description as addTagDescription,
hasReason as addTagHasReason
} from './AddTagAction'
import SendNotificationAction, {
eventName as sendNotificationEventName,
label as sendNotificationLabel,
description as sendNotificationDescription,
hasReason as sendNotificationHasReason
} from './SendNotificationAction'
const actionDefinitions: ActionDefinition[] = [
{
eventName: addScoreEventName,
label: addScoreLabel,
description: addScoreDescription,
component: AddScoreAction,
hasReason: addScoreHasReason
},
{
eventName: addTagEventName,
label: addTagLabel,
description: addTagDescription,
component: AddTagAction,
hasReason: addTagHasReason
},
{
eventName: sendNotificationEventName,
label: sendNotificationLabel,
description: sendNotificationDescription,
component: SendNotificationAction,
hasReason: sendNotificationHasReason
}
]
actionDefinitions.forEach((def) => actionRegistry.register(def))
export { AddScoreAction, AddTagAction, SendNotificationAction }
@@ -1,17 +0,0 @@
import './triggers'
import './actions'
export { triggerRegistry, actionRegistry, allTriggers, allActions } from './registry'
export type {
TriggerComponentProps,
ActionComponentProps,
TriggerDefinition,
ActionDefinition,
TriggerItem,
ActionItem
} from './types'
export { default as TriggerItemComponent } from './TriggerItem'
export { default as ActionItemComponent } from './ActionItem'
export { IntervalTimeTrigger, StudentTagTrigger, RandomTimeTrigger } from './triggers'
export { AddScoreAction, AddTagAction, SendNotificationAction } from './actions'
@@ -1,66 +0,0 @@
import type { TriggerDefinition, ActionDefinition, TriggerLogic } from './types'
class TriggerRegistry {
private triggers: Map<string, TriggerDefinition> = new Map()
register(definition: TriggerDefinition) {
this.triggers.set(definition.eventName, definition)
}
get(eventName: string): TriggerDefinition | undefined {
return this.triggers.get(eventName)
}
getAll(): TriggerDefinition[] {
return Array.from(this.triggers.values())
}
getOptions() {
return this.getAll().map((t) => ({ label: t.label, value: t.eventName }))
}
getLogic(eventName: string): TriggerLogic | undefined {
return this.triggers.get(eventName)?.triggerLogic
}
}
class ActionRegistry {
private actions: Map<string, ActionDefinition> = new Map()
register(definition: ActionDefinition) {
this.actions.set(definition.eventName, definition)
}
get(eventName: string): ActionDefinition | undefined {
return this.actions.get(eventName)
}
getAll(): ActionDefinition[] {
return Array.from(this.actions.values())
}
getOptions() {
return this.getAll().map((a) => ({ label: a.label, value: a.eventName }))
}
}
export const triggerRegistry = new TriggerRegistry()
export const actionRegistry = new ActionRegistry()
export const allTriggers = {
get list() {
return triggerRegistry.getAll()
},
get options() {
return triggerRegistry.getOptions()
}
}
export const allActions = {
get list() {
return actionRegistry.getAll()
},
get options() {
return actionRegistry.getOptions()
}
}
@@ -1,64 +0,0 @@
import { useState } from 'react'
import { InputNumber, Space, Radio } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'interval_time_passed'
export const label = '根据间隔时间触发'
export const description = '当间隔时间到达时触发自动化'
export const triggerLogic = {
eventName,
label,
description,
validate: (value: string) => {
const minutes = parseInt(value, 10)
if (isNaN(minutes) || minutes <= 0) {
return { valid: false, message: '请输入有效的时间间隔(分钟)' }
}
return { valid: true }
}
}
const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange }) => {
const numValue = value ? parseInt(value, 10) : undefined
const [unit, setUnit] = useState<'minutes' | 'days'>('minutes')
const handleChange = (v: number | null) => {
const numV = v
if (numV === undefined || numV === null || isNaN(numV)) {
onChange('')
return
}
const minutes = unit === 'minutes' ? numV : numV * 1440
onChange(String(Math.round(minutes)))
}
const displayValue =
numValue === undefined || isNaN(numValue)
? undefined
: unit === 'minutes'
? numValue
: Math.max(1, Math.round(numValue / 1440))
return (
<Space>
<InputNumber
placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'}
style={{ width: '100px' }}
value={displayValue}
onChange={handleChange}
min={1}
/>
<Radio.Group
value={unit}
onChange={(e) => setUnit(e.target.value as 'minutes' | 'days')}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="days"></Radio.Button>
<Radio.Button value="minutes"></Radio.Button>
</Radio.Group>
</Space>
)
}
export default IntervalTimeTrigger
@@ -1,72 +0,0 @@
import { InputNumber, Space } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'random_time_reached'
export const label = '随机时间触发'
export const description = '在指定时间范围内随机触发自动化'
export const triggerLogic = {
eventName,
label,
description,
validate: (value: string) => {
const minutes = parseInt(value, 10)
if (isNaN(minutes) || minutes <= 0) {
return { valid: false, message: '请输入有效的时间范围(分钟)' }
}
return { valid: true }
}
}
interface RandomTimeConfig {
minHour: number
maxHour: number
}
const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange }) => {
let config: RandomTimeConfig = { minHour: 9, maxHour: 18 }
try {
if (value) {
const parsed = JSON.parse(value)
config = { ...config, ...parsed }
}
} catch (e) {
console.debug('RandomTimeTrigger parse error', e)
}
const handleChange = (key: keyof RandomTimeConfig, v: number | null) => {
const numV = v ?? (key === 'minHour' ? 0 : 23)
const newConfig = { ...config, [key]: numV }
onChange(JSON.stringify(newConfig))
}
return (
<Space>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最小小时"
style={{ width: '70px' }}
value={config.minHour}
onChange={(v) => handleChange('minHour', v)}
min={0}
max={23}
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最大小时"
style={{ width: '70px' }}
value={config.maxHour}
onChange={(v) => handleChange('maxHour', v)}
min={0}
max={23}
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Space>
)
}
export default RandomTimeTrigger
@@ -1,50 +0,0 @@
import { useState, useEffect } from 'react'
import { Select } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'student_tag_matched'
export const label = '当学生匹配标签时触发'
export const description = '当学生匹配特定标签时触发自动化'
export const triggerLogic = {
eventName,
label,
description,
validate: (value: string) => {
if (!value || value.trim() === '') {
return { valid: false, message: '请输入标签名称' }
}
return { valid: true }
}
}
const StudentTagTrigger: React.FC<TriggerComponentProps> = ({ value, onChange }) => {
const [tags, setTags] = useState<{ label: string; value: string }[]>([])
useEffect(() => {
const fetchTags = async () => {
try {
const res = await (window as any).api.tagsGetAll()
if (res.success && res.data) {
setTags(res.data.map((tag: any) => ({ label: tag.name, value: tag.name })))
}
} catch (e) {
console.error('Failed to fetch tags', e)
}
}
fetchTags()
}, [])
return (
<Select
placeholder="请选择标签"
style={{ width: '150px' }}
value={value ?? undefined}
onChange={(v) => onChange(v ? String(v) : '')}
options={tags}
showSearch
allowClear
/>
)
}
export default StudentTagTrigger
@@ -1,51 +0,0 @@
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 }
@@ -1,56 +0,0 @@
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<TriggerComponentProps>
triggerLogic?: TriggerLogic
}
export interface ActionDefinition {
eventName: string
label: string
description: string
component: React.FC<ActionComponentProps>
hasReason?: boolean
}
export interface TriggerItem {
id: number
eventName: string
value: string
relation?: 'AND' | 'OR'
}
export interface ActionItem {
id: number
eventName: string
value: string
reason: string
}
+2 -56
View File
@@ -1,7 +1,5 @@
import { Service } from '../../../shared/kernel'
import { ClientContext } from '../ClientContext'
import { allTriggers, allActions } from '../components/com.automatically'
import type { TriggerItem, ActionItem } from '../components/com.automatically/types'
export interface AutoScoreRule {
id: number
@@ -9,7 +7,7 @@ export interface AutoScoreRule {
name: string
studentNames: string[]
lastExecuted?: string
triggers?: { event: string; value?: string }[]
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
actions?: { event: string; value?: string; reason?: string }[]
}
@@ -17,7 +15,7 @@ export interface AutoScoreRuleInput {
enabled: boolean
name: string
studentNames: string[]
triggers?: { event: string; value?: string }[]
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
actions?: { event: string; value?: string; reason?: string }[]
}
@@ -27,9 +25,6 @@ declare module '../../../shared/kernel' {
}
}
export { allTriggers, allActions }
export type { TriggerItem, ActionItem }
export class AutoScoreService extends Service {
constructor(ctx: ClientContext) {
super(ctx, 'autoScore')
@@ -73,53 +68,4 @@ export class AutoScoreService extends Service {
async getStatus(): Promise<{ success: boolean; data?: { enabled: boolean }; message?: string }> {
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.list[0]
return {
id: nextId,
eventName: defaultTrigger.eventName,
value: ''
}
}
updateTriggerEvent(triggerList: TriggerItem[], id: number, eventName: string): TriggerItem[] {
return triggerList.map((t) => (t.id === id ? { ...t, eventName, value: '' } : t))
}
updateTriggerValue(triggerList: TriggerItem[], id: number, value: string): TriggerItem[] {
return triggerList.map((t) => (t.id === id ? { ...t, value } : t))
}
deleteTriggerItem(triggerList: TriggerItem[], id: number): TriggerItem[] {
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.list[0]
return {
id: nextId,
eventName: defaultAction.eventName,
value: '',
reason: ''
}
}
updateActionEvent(actionList: ActionItem[], id: number, eventName: string): ActionItem[] {
return actionList.map((a) => (a.id === id ? { ...a, eventName, value: '' } : a))
}
updateActionValue(actionList: ActionItem[], id: number, value: string): ActionItem[] {
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)
}
}