diff --git a/package.json b/package.json index 6fbfa07..444be81 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,13 @@ "es-object-atoms": "^1.1.1", "express": "^5.2.1", "i18next": "^25.8.14", + "json-rules-engine": "^7.3.1", "mica-electron": "^1.5.16", "os": "^0.1.2", "pg": "^8.19.0", "pinyin-pro": "^3.27.0", "react-i18next": "^16.5.6", + "react-querybuilder": "^8.14.0", "react-router-dom": "^6.28.0", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03c7fdf..0456593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: i18next: specifier: ^25.8.14 version: 25.8.14(typescript@5.9.3) + json-rules-engine: + specifier: ^7.3.1 + version: 7.3.1 mica-electron: specifier: ^1.5.16 version: 1.5.16 @@ -63,6 +66,9 @@ importers: react-i18next: specifier: ^16.5.6 version: 16.5.6(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + react-querybuilder: + specifier: ^8.14.0 + version: 8.14.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) react-router-dom: specifier: ^6.28.0 version: 6.30.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 39ee4d2..c359974 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,7 +4,7 @@ import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-route import { useTranslation } from 'react-i18next' import { Sidebar } from './components/Sidebar' import { ContentArea } from './components/ContentArea' -import { OOBE } from './components/OOBE' +import { OOBE } from './components/OOBE/OOBE' import { ThemeProvider, useTheme } from './contexts/ThemeContext' function MainContent(): React.JSX.Element { diff --git a/src/renderer/src/components/AutoScoreManager.tsx b/src/renderer/src/components/AutoScoreManager.tsx index 0756bad..d0bc3c4 100644 --- a/src/renderer/src/components/AutoScoreManager.tsx +++ b/src/renderer/src/components/AutoScoreManager.tsx @@ -1,14 +1,12 @@ import { useState, useEffect } from 'react' -import { PlusOutlined, HolderOutlined } from '@ant-design/icons' +import { PlusOutlined, HolderOutlined, DeleteOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -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 RuleComponent from './autoScore/ruleComponent' import { Card, Form, Input, + InputNumber, Button, message, Table, @@ -21,38 +19,84 @@ 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 name: string studentNames: string[] lastExecuted?: string - triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[] - actions?: { event: string; value?: string; reason?: string }[] + triggers?: TriggerItem[] + actions?: ActionItem[] +} + +interface TriggerItem { + id: number + eventName: string + value: string + relation: 'AND' | 'OR' +} + +interface ActionItem { + id: number + eventName: string + value: string + reason: string +} +interface TagItem { + id: number + name: string } interface AutoScoreRuleFormValues { name: string - studentNames: string + studentNames: string[] } +const TRIGGER_DEFINITIONS = [ + { eventName: 'interval_time_passed', labelKey: 'autoScore.triggerIntervalTime' }, + { eventName: 'student_has_tag', labelKey: 'autoScore.triggerStudentTag' } +] + +const ACTION_DEFINITIONS = [ + { eventName: 'add_score', labelKey: 'autoScore.actionAddScore' }, + { eventName: 'add_tag', labelKey: 'autoScore.actionAddTag' } +] + export const AutoScoreManager: React.FC = () => { const { t } = useTranslation() const [rules, setRules] = useState([]) + const [allTags, setAllTags] = useState([]) const [students, setStudents] = useState<{ id: number; name: string }[]>([]) const [loading, setLoading] = useState(false) const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(50) const [form] = Form.useForm() const [editingRuleId, setEditingRuleId] = useState(null) - const [ruleData, setRuleData] = useState({ - triggers: [], - actions: [] - }) + const [triggerList, setTriggerList] = useState([]) + const [actionList, setActionList] = useState([]) const [messageApi, contextHolder] = message.useMessage() + const getTriggerLabel = (eventName: string): string => { + const def = TRIGGER_DEFINITIONS.find((d) => d.eventName === eventName) + return def ? t(def.labelKey) : eventName + } + + const getActionLabel = (eventName: string): string => { + const def = ACTION_DEFINITIONS.find((d) => d.eventName === eventName) + return def ? t(def.labelKey) : eventName + } + + async function fetchAllTags() { + if (!(window as any).api) return + try { + const res = await (window as any).api.tagsGetAll() + if (res.success && res.data) { + setAllTags(res.data) + } + } catch (e) { + console.error('Failed to fetch tags:', e) + messageApi.error(t('tags.fetchFailed')) + } + } const fetchRules = async () => { if (!(window as any).api) return @@ -116,12 +160,24 @@ export const AutoScoreManager: React.FC = () => { const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] + const triggers = triggerList.map((t) => ({ + event: t.eventName, + value: t.value, + relation: t.relation + })) + + const actions = actionList.map((a) => ({ + event: a.eventName, + value: a.value, + reason: a.reason + })) + const ruleDataToSubmit = { enabled: true, name: values.name, studentNames, - triggers: ruleData.triggers, - actions: ruleData.actions + triggers, + actions } try { @@ -151,10 +207,11 @@ export const AutoScoreManager: React.FC = () => { ) form.setFieldsValue({ name: '', - studentNames: '' + studentNames: [] }) setEditingRuleId(null) - setRuleData({ triggers: [], actions: [] }) + setTriggerList([]) + setActionList([]) fetchRules() } else { messageApi.error( @@ -176,10 +233,22 @@ export const AutoScoreManager: React.FC = () => { name: rule.name, studentNames: rule.studentNames }) - setRuleData({ - triggers: rule.triggers || [], - actions: rule.actions || [] - }) + setTriggerList( + (rule.triggers || []).map((t, index) => ({ + id: index + 1, + eventName: t.eventName, + value: t.value || '', + relation: t.relation || 'AND' + })) + ) + setActionList( + (rule.actions || []).map((a, index) => ({ + id: index + 1, + eventName: a.eventName, + value: a.value || '', + reason: a.reason || '' + })) + ) } const handleDelete = async (ruleId: number) => { @@ -239,7 +308,7 @@ export const AutoScoreManager: React.FC = () => { const handleResetForm = () => { form.setFieldsValue({ name: '', - studentNames: '' + studentNames: [] }) setEditingRuleId(null) setTriggerList([]) @@ -248,7 +317,7 @@ export const AutoScoreManager: React.FC = () => { const handleAddTrigger = () => { const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1 - const defaultTrigger = allTriggers.list[0] + const defaultTrigger = TRIGGER_DEFINITIONS[0] if (!defaultTrigger) { messageApi.error(t('autoScore.noTriggerAvailable')) return @@ -264,25 +333,23 @@ export const AutoScoreManager: React.FC = () => { ]) } - const handleDeleteTrigger = (id: number) => { + const handleRemoveTrigger = (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 handleTriggerChange = ( + id: number, + field: keyof TriggerItem, + value: string | number | 'AND' | 'OR' + ) => { + setTriggerList((prev) => + prev.map((t) => (t.id === id ? { ...t, [field]: value } : t)) + ) } const handleAddAction = () => { const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1 - const defaultAction = allActions.list[0] + const defaultAction = ACTION_DEFINITIONS[0] if (!defaultAction) { messageApi.error(t('autoScore.noActionAvailable')) return @@ -298,20 +365,18 @@ export const AutoScoreManager: React.FC = () => { ]) } - const handleDeleteAction = (id: number) => { + const handleRemoveAction = (id: number) => { setActionList((prev) => prev.filter((a) => a.id !== id)) } - const handleActionChange = (id: number, eventName: string) => { - setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, eventName, value: '' } : a))) - } - - const handleActionValueChange = (id: number, value: string) => { - setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, value } : a))) - } - - const handleActionReasonChange = (id: number, reason: string) => { - setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a))) + const handleActionChange = ( + id: number, + field: keyof ActionItem, + value: string | number + ) => { + setActionList((prev) => + prev.map((a) => (a.id === id ? { ...a, [field]: value } : a)) + ) } const columns: ColumnsType = [ @@ -340,10 +405,7 @@ export const AutoScoreManager: React.FC = () => { if (!triggers || triggers.length === 0) { return {t('common.none')} } - const triggerLabels = triggers.map((t) => { - const def = triggerRegistry.get(t.event) - return def?.label || t.event - }) + const triggerLabels = triggers.map((t) => getTriggerLabel(t.eventName)) return ( {t('autoScore.triggerCount', { count: triggers.length })} @@ -360,10 +422,7 @@ export const AutoScoreManager: React.FC = () => { if (!actions || actions.length === 0) { return {t('common.none')} } - const actionLabels = actions.map((a) => { - const def = actionRegistry.get(a.event) - return def?.label || a.event - }) + const actionLabels = actions.map((a) => getActionLabel(a.eventName)) return ( {t('autoScore.actionCount', { count: actions.length })} @@ -422,6 +481,127 @@ export const AutoScoreManager: React.FC = () => { } ] + const renderTriggerItem = (trigger: TriggerItem, index: number) => ( +
+ + {index > 0 && ( + handleTriggerChange(trigger.id, 'eventName', value)} + style={{ width: 150 }} + options={TRIGGER_DEFINITIONS.map((d) => ({ + label: t(d.labelKey), + value: d.eventName + }))} + /> + {trigger.eventName === 'interval_time_passed' && ( + handleTriggerChange(trigger.id, 'value', String(value || 0))} + placeholder={t('autoScore.minutesPlaceholder')} + min={1} + style={{ width: 120 }} + addonAfter={t('autoScore.minutes')} + /> + )} + {trigger.eventName === 'student_has_tag' && ( + handleActionChange(action.id, 'eventName', value)} + style={{ width: 150 }} + options={ACTION_DEFINITIONS.map((d) => ({ + label: t(d.labelKey), + value: d.eventName + }))} + /> + {action.eventName === 'add_score' && ( + handleActionChange(action.id, 'value', String(value || 0))} + placeholder={t('autoScore.scorePlaceholder')} + min={-100} + max={100} + style={{ width: 120 }} + /> + )} + {action.eventName === 'add_tag' && ( + handleActionChange(action.id, 'value', e.target.value)} + placeholder={t('autoScore.tagNamePlaceholder')} + style={{ width: 200 }} + /> + )} + handleActionChange(action.id, 'reason', e.target.value)} + placeholder={t('autoScore.reasonPlaceholder')} + style={{ width: 200 }} + /> +
+ ) + return (
{contextHolder} @@ -447,21 +627,6 @@ export const AutoScoreManager: React.FC = () => { />
- -
- setRuleData(data)} /> -
- -
- - -
@@ -470,8 +635,9 @@ export const AutoScoreManager: React.FC = () => { title={t('autoScore.whenTriggered')} > - {triggerItems} - + + + { ) -} +} \ No newline at end of file diff --git a/src/renderer/src/components/OOBE.tsx b/src/renderer/src/components/OOBE/OOBE.tsx similarity index 99% rename from src/renderer/src/components/OOBE.tsx rename to src/renderer/src/components/OOBE/OOBE.tsx index e658547..2f5f16a 100644 --- a/src/renderer/src/components/OOBE.tsx +++ b/src/renderer/src/components/OOBE/OOBE.tsx @@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next' import { Button, Segmented, Input, Tag, message, Typography, InputNumber } from 'antd' import { PlusOutlined, UploadOutlined, FileExcelOutlined } from '@ant-design/icons' import { OOBEBackground } from './OOBEBackground' -import { useTheme } from '../contexts/ThemeContext' -import { changeLanguage, AppLanguage, languageOptions } from '../i18n' -import type { themeConfig } from '../../../preload/types' -import logoSvg from '../assets/logoHD.svg' +import { useTheme } from '../../contexts/ThemeContext' +import { changeLanguage, AppLanguage, languageOptions } from '../../i18n' +import type { themeConfig } from '../../../../preload/types' +import logoSvg from '../../assets/logoHD.svg' interface oobeProps { visible: boolean diff --git a/src/renderer/src/components/OOBEBackground.tsx b/src/renderer/src/components/OOBE/OOBEBackground.tsx similarity index 100% rename from src/renderer/src/components/OOBEBackground.tsx rename to src/renderer/src/components/OOBE/OOBEBackground.tsx diff --git a/src/renderer/src/components/autoScore/actionComponent.tsx b/src/renderer/src/components/autoScore/actionComponent.tsx index 6b4579b..b7d5be9 100644 --- a/src/renderer/src/components/autoScore/actionComponent.tsx +++ b/src/renderer/src/components/autoScore/actionComponent.tsx @@ -1,5 +1,6 @@ import { Space, Input, InputNumber, Select, Button } from 'antd' import { PlusOutlined, DeleteOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' import type { AutoScoreAction } from './ruleBuilderUtils' interface ActionComponentProps { @@ -9,19 +10,26 @@ interface ActionComponentProps { onChange: (index: number, field: keyof AutoScoreAction, value: any) => void } +const ACTION_DEFINITIONS = [ + { eventName: 'add_score', labelKey: 'autoScore.actionAddScore' }, + { eventName: 'add_tag', labelKey: 'autoScore.actionAddTag' } +] + export const ActionComponent: React.FC = ({ actions, onAdd, onRemove, onChange }) => { + const { t } = useTranslation() + const handleActionChange = (index: number, field: keyof AutoScoreAction, value: any) => { onChange(index, field, value) } return (
- + {actions.map((action, index) => (
= ({ padding: '12px', border: '1px solid #d9d9d9', borderRadius: '6px', - backgroundColor: '#fafafa' + backgroundColor: 'var(--ss-card-bg)' }} > @@ -37,12 +45,9 @@ export const ActionComponent: React.FC = ({ 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 + options={ACTION_DEFINITIONS.map((d) => ({ + label: t(d.labelKey), + value: d.eventName }))} /> @@ -50,7 +55,7 @@ export const ActionComponent: React.FC = ({ handleActionChange(index, 'value', String(value || 0))} - placeholder="分数" + placeholder={t('autoScore.scoreLabel')} min={-100} max={100} style={{ width: 120 }} @@ -61,7 +66,7 @@ export const ActionComponent: React.FC = ({ handleActionChange(index, 'value', e.target.value)} - placeholder="标签名称" + placeholder={t('autoScore.tagNameLabel')} style={{ width: 200 }} /> )} @@ -69,7 +74,7 @@ export const ActionComponent: React.FC = ({ handleActionChange(index, 'reason', e.target.value)} - placeholder="操作说明(可选)" + placeholder={t('autoScore.operationNoteLabel')} style={{ flex: 1, minWidth: 200 }} /> @@ -84,11 +89,11 @@ export const ActionComponent: React.FC = ({ ))}
) } -export default ActionComponent +export default ActionComponent \ No newline at end of file diff --git a/src/renderer/src/components/autoScore/ruleBuilderUtils.ts b/src/renderer/src/components/autoScore/ruleBuilderUtils.ts index d521d88..b2b9dfb 100644 --- a/src/renderer/src/components/autoScore/ruleBuilderUtils.ts +++ b/src/renderer/src/components/autoScore/ruleBuilderUtils.ts @@ -17,36 +17,46 @@ export interface AutoScoreRuleData { actions: AutoScoreAction[] } -export const TRIGGER_TYPES = { +// i18n key definitions for triggers and actions +export const TRIGGER_TYPE_KEYS = { interval_time_passed: { - label: '间隔时间', - description: '每隔指定时间自动触发' + labelKey: 'autoScore.triggerIntervalTime', + descriptionKey: 'triggers.intervalTime.description' }, student_has_tag: { - label: '学生标签', - description: '当学生拥有指定标签时触发' + labelKey: 'autoScore.triggerStudentTag', + descriptionKey: 'triggers.studentTag.description' } } -export const ACTION_TYPES = { +export const ACTION_TYPE_KEYS = { add_score: { - label: '加分', - description: '为学生增加分数' + labelKey: 'autoScore.actionAddScore', + descriptionKey: 'actions.addScore.description' }, add_tag: { - label: '添加标签', - description: '为学生添加标签' + labelKey: 'autoScore.actionAddTag', + descriptionKey: 'actions.addTag.description' } } -export const fields: Field[] = [ - { name: 'interval_time_passed', label: '间隔时间(分钟)', placeholder: '例如:1440' }, - { name: 'student_has_tag', label: '学生标签', placeholder: '例如:优秀学生,班干部' } +// Function to get fields with i18n support +export const getFields = (t: (key: string) => string): Field[] => [ + { + name: 'interval_time_passed', + label: t('autoScore.triggerIntervalTime'), + placeholder: t('autoScore.intervalMinutesPlaceholder') + }, + { + name: 'student_has_tag', + label: t('autoScore.triggerStudentTag'), + placeholder: t('autoScore.tagNamesPlaceholder') + } ] export const operators: Operator[] = [ - { name: '=', label: '等于' }, - { name: 'contains', label: '包含' } + { name: '=', label: '=' }, + { name: 'contains', label: 'contains' } ] export const defaultQuery: RuleGroupType = { @@ -106,4 +116,4 @@ export function autoScoreRuleToQuery(ruleData: AutoScoreRuleData): RuleGroupType combinator: currentCombinator, rules: rules.length > 0 ? rules : defaultQuery.rules } -} +} \ No newline at end of file diff --git a/src/renderer/src/components/autoScore/ruleComponent.tsx b/src/renderer/src/components/autoScore/ruleComponent.tsx index 9dc137e..9bff252 100644 --- a/src/renderer/src/components/autoScore/ruleComponent.tsx +++ b/src/renderer/src/components/autoScore/ruleComponent.tsx @@ -1,7 +1,8 @@ import { formatQuery, QueryBuilder, RuleGroupType } from 'react-querybuilder' import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { - fields, + getFields, operators, defaultQuery, queryToAutoScoreRule, @@ -20,6 +21,7 @@ interface RuleComponentProps { } export const RuleComponent: React.FC = ({ initialData, onChange }) => { + const { t } = useTranslation() const [query, setQuery] = useState( initialData ? autoScoreRuleToQuery(initialData) : defaultQuery ) @@ -55,9 +57,9 @@ export const RuleComponent: React.FC = ({ initialData, onCha return (
- + = ({ initialData, onCha
- + = ({ initialData, onCha ) } -export default RuleComponent +export default RuleComponent \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/en-US.json b/src/renderer/src/i18n/locales/en-US.json index 6708507..c3e6587 100644 --- a/src/renderer/src/i18n/locales/en-US.json +++ b/src/renderer/src/i18n/locales/en-US.json @@ -495,7 +495,25 @@ "triggerCount": "{{count}} triggers", "actionCount": "{{count}} actions", "studentCount": "{{count}} students", - "studentPlaceholder": "Please select or search students (leave empty for all students)" + "studentPlaceholder": "Please select or search students (leave empty for all students)", + "triggerCondition": "Trigger Condition", + "executeAction": "Execute Action", + "addOperation": "Add Operation", + "scoreLabel": "Score", + "tagNameLabel": "Tag Name", + "operationNoteLabel": "Operation Note (Optional)", + "intervalMinutesPlaceholder": "e.g. 1440", + "tagNamesPlaceholder": "e.g. excellent_student,class_monitor", + "triggerIntervalTime": "Interval Time", + "triggerStudentTag": "Student Tag", + "actionAddScore": "Add Score", + "actionAddTag": "Add Tag", + "minutesPlaceholder": "Enter minutes", + "minutes": "minutes", + "tagsPlaceholder": "Enter tag name", + "scorePlaceholder": "Enter score", + "tagNamePlaceholder": "Enter tag name", + "reasonPlaceholder": "Enter reason" }, "triggers": { "studentTag": { diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 9fd4ee3..86fc876 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -246,7 +246,7 @@ "leaderboard": "排行榜", "settlements": "结算历史", "reasons": "理由管理", - "autoScore": "自动评分", + "autoScore": "自动加分", "settings": "系统设置", "remoteDb": "远程数据库", "refreshStatus": "刷新状态", @@ -495,7 +495,25 @@ "triggerCount": "{{count}} 个触发器", "actionCount": "{{count}} 个行动", "studentCount": "{{count}} 名学生", - "studentPlaceholder": "请选择或搜索学生(留空表示所有学生)" + "studentPlaceholder": "请选择或搜索学生(留空表示所有学生)", + "triggerCondition": "触发条件", + "executeAction": "执行操作", + "addOperation": "添加操作", + "scoreLabel": "分数", + "tagNameLabel": "标签名称", + "operationNoteLabel": "操作说明(可选)", + "intervalMinutesPlaceholder": "例如:1440", + "tagNamesPlaceholder": "例如:优秀学生,班干部", + "triggerIntervalTime": "间隔时间", + "triggerStudentTag": "学生标签", + "actionAddScore": "加分", + "actionAddTag": "添加标签", + "minutesPlaceholder": "请输入分钟数", + "minutes": "分钟", + "tagsPlaceholder": "请输入标签名称", + "scorePlaceholder": "请输入分数", + "tagNamePlaceholder": "请输入标签名称", + "reasonPlaceholder": "请输入理由" }, "triggers": { "studentTag": {