mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
FEFACTARY:自动化加分beta
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user