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
+2
View File
@@ -40,10 +40,12 @@
"chokidar": "^5.0.0",
"es-object-atoms": "^1.1.1",
"express": "^5.2.1",
"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-querybuilder": "^8.14.0",
"react-router-dom": "^6.28.0",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.27",
+232
View File
@@ -47,6 +47,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
json-rules-engine:
specifier: ^7.3.1
version: 7.3.1
mica-electron:
specifier: ^1.5.16
version: 1.5.16
@@ -57,6 +60,9 @@ importers:
pinyin-pro:
specifier: ^3.27.0
version: 3.27.0
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)
@@ -819,6 +825,18 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsep-plugin/assignment@1.3.0':
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@jsep-plugin/regex@1.0.4':
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
@@ -1126,6 +1144,20 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
'@react-querybuilder/core@8.14.0':
resolution: {integrity: sha512-j1pIY0Yyn/dXu9ZST/DVY7TqRmIO1hY/mZ8653DaeHaDzUF37tOdkm/NQDU9RfM0KXIWsJY5zlvYAR1DypZ+7g==}
peerDependencies:
drizzle-orm: '>=0.38.0'
json-logic-js: '>=2'
sequelize: '>=6'
peerDependenciesMeta:
drizzle-orm:
optional: true
json-logic-js:
optional: true
sequelize:
optional: true
'@react-spring/animated@9.6.1':
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
peerDependencies:
@@ -1153,6 +1185,17 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
@@ -1194,66 +1237,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1295,10 +1351,25 @@ packages:
'@sqltools/formatter@1.2.5':
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
'@ts-jison/common@0.4.1-alpha.1':
resolution: {integrity: sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ==}
'@ts-jison/lexer@0.4.1-alpha.1':
resolution: {integrity: sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow==}
'@ts-jison/parser@0.4.1-alpha.1':
resolution: {integrity: sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1370,6 +1441,9 @@ packages:
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@11.0.0':
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
@@ -1782,6 +1856,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -2202,6 +2280,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@@ -2450,6 +2531,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash-it@6.0.1:
resolution: {integrity: sha512-qhl8+l4Zwi1eLlL3lja5ywmDQnBzLEJxd0QJoAVIgZpgQbdtVZrN5ypB0y3VHwBlvAalpcbM2/A6x7oUks5zNg==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2507,6 +2591,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2717,6 +2804,10 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsep@1.4.0:
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
engines: {node: '>= 10.16.0'}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -2725,6 +2816,10 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-rules-engine@7.3.1:
resolution: {integrity: sha512-NyRTQZllvAt7AQ3g9P7/t4nIwlEB+EyZV7y8/WgXfZWSlpcDryt1UH9CsoU+Z+MDvj8umN9qqEcbE6qnk9JAHw==}
engines: {node: '>=18.0.0'}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -2748,6 +2843,11 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonpath-plus@10.4.0:
resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==}
engines: {node: '>=18.0.0'}
hasBin: true
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -3010,6 +3110,10 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
numeric-quantity@2.1.0:
resolution: {integrity: sha512-oDkQ8nFuNVA+unEg1jd6dAS+O7eLXWWzsa4ViI0S0yFi6654GK0s74o8bF8uLRQdWIz/qFF1GABNFPfwAGQUsg==}
engines: {node: '>=16'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3319,6 +3423,23 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-querybuilder@8.14.0:
resolution: {integrity: sha512-uwJn1XT4A6reuxjPmRLUnvewhC4PZksZU4XSxCJgqwR37r2A1/REvxEgv+zVQGVFcd4dUIZs1E++WDabOWVlmA==}
peerDependencies:
react: '>=18'
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -3352,6 +3473,14 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -3371,6 +3500,9 @@ packages:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -4779,6 +4911,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6
@@ -5158,6 +5298,13 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@react-querybuilder/core@8.14.0':
dependencies:
'@ts-jison/lexer': 0.4.1-alpha.1
'@ts-jison/parser': 0.4.1-alpha.1
immer: 11.1.4
numeric-quantity: 2.1.0
'@react-spring/animated@9.6.1(react@19.2.3)':
dependencies:
'@react-spring/shared': 9.6.1(react@19.2.3)
@@ -5191,6 +5338,18 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
'@remix-run/router@1.23.2': {}
'@rolldown/pluginutils@1.0.0-beta.53': {}
@@ -5279,10 +5438,25 @@ snapshots:
'@sqltools/formatter@1.2.5': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
'@ts-jison/common@0.4.1-alpha.1': {}
'@ts-jison/lexer@0.4.1-alpha.1':
dependencies:
'@ts-jison/common': 0.4.1-alpha.1
'@ts-jison/parser@0.4.1-alpha.1':
dependencies:
'@ts-jison/common': 0.4.1-alpha.1
'@ts-jison/lexer': 0.4.1-alpha.1
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.5
@@ -5373,6 +5547,8 @@ snapshots:
'@types/triple-beam@1.3.5': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@11.0.0':
dependencies:
uuid: 13.0.0
@@ -5974,6 +6150,8 @@ snapshots:
clone@1.0.4: {}
clone@2.1.2: {}
clsx@2.1.1: {}
codepage@1.15.0: {}
@@ -6551,6 +6729,8 @@ snapshots:
etag@1.8.1: {}
eventemitter2@6.4.9: {}
expand-template@2.0.3: {}
exponential-backoff@3.1.3: {}
@@ -6859,6 +7039,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash-it@6.0.1: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -6922,6 +7104,8 @@ snapshots:
ignore@7.0.5: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -7117,10 +7301,19 @@ snapshots:
dependencies:
argparse: 2.0.1
jsep@1.4.0: {}
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-rules-engine@7.3.1:
dependencies:
clone: 2.1.2
eventemitter2: 6.4.9
hash-it: 6.0.1
jsonpath-plus: 10.4.0
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
@@ -7144,6 +7337,12 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonpath-plus@10.4.0:
dependencies:
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@@ -7402,6 +7601,8 @@ snapshots:
normalize-url@6.1.0: {}
numeric-quantity@2.1.0: {}
object-assign@4.1.1: {}
object-hash@3.0.0: {}
@@ -7725,6 +7926,29 @@ snapshots:
react-is@18.3.1: {}
react-querybuilder@8.14.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1):
dependencies:
'@react-querybuilder/core': 8.14.0
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
immer: 11.1.4
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
transitivePeerDependencies:
- '@types/react'
- drizzle-orm
- json-logic-js
- redux
- sequelize
react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
redux: 5.0.1
react-refresh@0.18.0: {}
react-router-dom@6.30.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -7755,6 +7979,12 @@ snapshots:
readdirp@5.0.0: {}
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect-metadata@0.2.2: {}
reflect.getprototypeof@1.0.10:
@@ -7783,6 +8013,8 @@ snapshots:
dependencies:
pe-library: 0.4.1
reselect@5.1.1: {}
resize-observer-polyfill@1.5.1: {}
resolve-alpn@1.2.1: {}
+23
View File
@@ -224,4 +224,27 @@ export class EventRepository extends Service {
const rows = await qb.getRawMany()
return { startTime, rows }
}
async getLastScoreTimeByStudents(
studentNames: string[]
): Promise<Map<string, Date>> {
if (studentNames.length === 0) return new Map()
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
const results = await repo
.createQueryBuilder('e')
.select('e.student_name', 'student_name')
.addSelect('MAX(e.event_time)', 'last_time')
.where('e.student_name IN (:...studentNames)', { studentNames })
.groupBy('e.student_name')
.getRawMany()
const map = new Map<string, Date>()
for (const row of results) {
if (row.last_time) {
map.set(row.student_name, new Date(row.last_time))
}
}
return map
}
}
+88 -191
View File
@@ -1,16 +1,10 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { student } from '../repos/StudentRepository'
import { getTriggerLogic } from '../../shared/triggers'
import { AutoScoreRuleEngine, type RuleConfig, type AutoScoreContext } from '../../shared/autoScore/AutoScoreRuleEngine'
interface AutoScoreRule {
interface AutoScoreRule extends RuleConfig {
id: number
enabled: boolean
name: string
studentNames: string[]
lastExecuted?: Date
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
actions?: { event: string; value?: string; reason?: string }[]
}
interface AutoScoreRuleFileData {
@@ -19,8 +13,8 @@ interface AutoScoreRuleFileData {
name: string
studentNames: string[]
lastExecuted?: string
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
actions?: { event: string; value?: string; reason?: string }[]
triggers?: Array<{ event: string; value?: string; relation?: 'AND' | 'OR' }>
actions?: Array<{ event: string; value?: string; reason?: string }>
}
interface AutoScoreRulesFile {
@@ -41,9 +35,11 @@ export class AutoScoreService extends Service {
private rules: AutoScoreRule[] = []
private timers: Map<number, NodeJS.Timeout> = new Map()
private initialized = false
private ruleEngine: AutoScoreRuleEngine
constructor(ctx: MainContext) {
super(ctx, 'autoScore')
this.ruleEngine = new AutoScoreRuleEngine()
this.registerIpc()
}
@@ -116,6 +112,17 @@ export class AutoScoreService extends Service {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('auto-score:sortRules', async (event, ruleIds) => {
try {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const success = await this.sortRules(ruleIds)
return { success, data: success }
} catch (err: any) {
return { success: false, message: err.message }
}
})
}
private async loadRulesFromFile(): Promise<void> {
@@ -260,8 +267,11 @@ export class AutoScoreService extends Service {
this.stopRules()
}
this.ruleEngine = new AutoScoreRuleEngine()
for (const rule of this.rules) {
if (rule.enabled) {
await this.ruleEngine.addRule(rule)
this.startRuleTimer(rule)
}
}
@@ -280,11 +290,20 @@ export class AutoScoreService extends Service {
let primaryTrigger: { event: string; value?: string } | undefined
for (const trigger of rule.triggers || []) {
const logic = getTriggerLogic(trigger.event)
if (logic?.calculateNextTime) {
const result = logic.calculateNextTime(trigger.value || '', rule.lastExecuted, now)
if (delayMs === 0 || result.delayMs < delayMs) {
delayMs = result.delayMs
if (trigger.event === 'interval_time_passed') {
const minutes = parseInt(trigger.value || '30', 10)
const intervalMs = minutes * 60 * 1000
let nextExecuteTime: Date
if (rule.lastExecuted) {
nextExecuteTime = new Date(rule.lastExecuted.getTime() + intervalMs)
} else {
nextExecuteTime = new Date(now.getTime() + intervalMs)
}
const triggerDelayMs = nextExecuteTime.getTime() - now.getTime()
if (delayMs === 0 || triggerDelayMs < delayMs) {
delayMs = triggerDelayMs
primaryTrigger = trigger
}
}
@@ -314,11 +333,20 @@ export class AutoScoreService extends Service {
let primaryTrigger: { event: string; value?: string } | undefined
for (const trigger of rule.triggers || []) {
const logic = getTriggerLogic(trigger.event)
if (logic?.calculateNextTime) {
const result = logic.calculateNextTime(trigger.value || '', rule.lastExecuted, now)
if (result.delayMs < minDelayMs) {
minDelayMs = result.delayMs
if (trigger.event === 'interval_time_passed') {
const minutes = parseInt(trigger.value || '30', 10)
const intervalMs = minutes * 60 * 1000
let nextExecuteTime: Date
if (rule.lastExecuted) {
nextExecuteTime = new Date(rule.lastExecuted.getTime() + intervalMs)
} else {
nextExecuteTime = new Date(now.getTime() + intervalMs)
}
const triggerDelayMs = nextExecuteTime.getTime() - now.getTime()
if (triggerDelayMs < minDelayMs) {
minDelayMs = triggerDelayMs
primaryTrigger = trigger
}
}
@@ -340,60 +368,25 @@ export class AutoScoreService extends Service {
this.logger.info(`Executing auto score rule: ${rule.name}`)
const studentRepo = this.mainCtx.students
const eventRepo = this.mainCtx.events
let studentsToScore: student[] = []
if (rule.studentNames.length === 0) {
const allStudents = await studentRepo.findAll()
studentsToScore = allStudents
} else {
const allStudents = await studentRepo.findAll()
for (const name of rule.studentNames) {
const student = allStudents.find((s) => s.name === name)
if (student) {
studentsToScore.push(student)
}
}
const allStudents = await studentRepo.findAll()
let studentsToScore = allStudents
if (rule.studentNames.length > 0) {
studentsToScore = allStudents.filter((s) => rule.studentNames.includes(s.name))
}
// 使用AND/OR逻辑评估触发器
const matchedStudents = this.evaluateTriggersWithLogic(rule, studentsToScore)
const studentNames = studentsToScore.map((s) => s.name)
const lastScoreTimeMap = await eventRepo.getLastScoreTimeByStudents(studentNames)
if (matchedStudents.length > 0) {
for (const action of rule.actions || []) {
await this.executeAction(action, matchedStudents, rule.name)
}
}
rule.lastExecuted = new Date()
await this.saveRulesToFile()
this.logger.info(
`Auto score rule executed successfully for ${matchedStudents.length} students`
)
} catch (error) {
this.logger.error(`Failed to execute auto score rule ${rule.name}:`, { error })
}
}
private evaluateTriggersWithLogic(rule: AutoScoreRule, initialStudents: student[]): student[] {
if (!rule.triggers || rule.triggers.length === 0) {
return []
}
let resultStudents: student[] = [...initialStudents]
let currentGroup: student[] = [...initialStudents]
let currentRelation: 'AND' | 'OR' = 'AND'
for (let i = 0; i < rule.triggers.length; i++) {
const trigger = rule.triggers[i]
const logic = getTriggerLogic(trigger.event)
if (!logic?.check) {
continue
}
const context = {
students: currentGroup,
const context: AutoScoreContext = {
students: studentsToScore.map((s) => ({
id: s.id,
name: s.name,
tags: s.tags || [],
lastScoreTime: lastScoreTimeMap.get(s.name) || new Date(0),
})),
events: [],
rule: {
id: rule.id,
@@ -405,138 +398,39 @@ export class AutoScoreService extends Service {
now: new Date()
}
const result = logic.check(context, trigger.value || '')
const results = await this.ruleEngine.run(context)
if (!result.matchedStudents || result.matchedStudents.length === 0) {
// 当前触发器没有匹配的学生
if (currentRelation === 'AND') {
// AND关系下,任何一个触发器不匹配,整个组就不匹配
currentGroup = []
}
// OR关系下,继续评估下一个触发器
} else {
// 当前触发器有匹配的学生
if (currentRelation === 'AND') {
// AND关系下,取交集
currentGroup = currentGroup.filter((student) =>
result.matchedStudents!.some((matched) => matched.id === student.id)
)
} else {
// OR关系下,取并集
const newStudents = result.matchedStudents.filter(
(matched) => !currentGroup.some((student) => student.id === matched.id)
)
currentGroup = [...currentGroup, ...newStudents]
}
}
// 处理下一个关系(如果存在)
if (i < rule.triggers.length - 1) {
const nextRelation = rule.triggers[i + 1].relation || 'AND'
if (nextRelation !== currentRelation) {
// 关系发生变化,处理当前组的结果
if (currentRelation === 'AND') {
// AND组结束,如果当前组不为空,则合并到结果
if (currentGroup.length > 0) {
resultStudents = resultStudents.filter((student) =>
currentGroup.some((groupStudent) => groupStudent.id === student.id)
)
} else {
// AND组为空,整个规则不匹配
return []
}
} else {
// OR组结束,合并当前组到结果
const newStudents = currentGroup.filter(
(groupStudent) =>
!resultStudents.some((resultStudent) => resultStudent.id === groupStudent.id)
for (const result of results) {
if (result.matchedStudents.length > 0) {
for (const action of result.actions) {
await this.ruleEngine.executeAction(
action.event,
result.matchedStudents,
{
...action,
ruleId: rule.id,
ruleName: rule.name,
studentRepo
},
eventRepo
)
resultStudents = [...resultStudents, ...newStudents]
}
// 重置当前组为所有学生,开始新的关系组
currentGroup = [...initialStudents]
currentRelation = nextRelation
}
}
}
// 处理最后一组的结果
if (currentRelation === 'AND') {
// AND组结束,如果当前组不为空,则合并到结果
if (currentGroup.length > 0) {
resultStudents = resultStudents.filter((student) =>
currentGroup.some((groupStudent) => groupStudent.id === student.id)
)
} else {
// AND组为空,整个规则不匹配
return []
}
} else {
// OR组结束,合并当前组到结果
const newStudents = currentGroup.filter(
(groupStudent) =>
!resultStudents.some((resultStudent) => resultStudent.id === groupStudent.id)
rule.lastExecuted = new Date()
await this.saveRulesToFile()
this.logger.info(
`Auto score rule executed successfully for ${results.reduce((sum, r) => sum + r.matchedStudents.length, 0)} students`
)
resultStudents = [...resultStudents, ...newStudents]
}
return resultStudents
}
private async executeAction(
action: { event: string; value?: string; reason?: string },
students: student[],
ruleName: string
) {
const eventRepo = this.mainCtx.events
switch (action.event) {
case 'add_score': {
const scoreValue = action.value ? parseInt(action.value, 10) : 0
const reason = action.reason || `自动化加分 - ${ruleName}`
for (const student of students) {
await eventRepo.create({
student_name: student.name,
reason_content: reason,
delta: scoreValue
})
}
break
}
case 'add_tag': {
const tagName = action.value
if (tagName) {
const studentRepo = this.mainCtx.students
for (const student of students) {
const currentTags = student.tags || []
if (!currentTags.includes(tagName)) {
await studentRepo.update(student.id, {
tags: [...currentTags, tagName]
})
}
}
}
break
}
case 'send_notification': {
this.logger.info(`Notification action: ${action.value}`)
break
}
case 'set_student_status': {
this.logger.info(
`Set student status action: ${action.value} (not implemented - student type has no status field)`
)
break
}
default:
this.logger.warn(`Unknown action event: ${action.event}`)
} catch (error) {
this.logger.error(`Failed to execute auto score rule ${rule.name}:`, { error })
}
}
private stopRules() {
for (const [timer] of this.timers) {
for (const timer of this.timers.values()) {
clearTimeout(timer)
clearInterval(timer)
}
@@ -555,6 +449,7 @@ export class AutoScoreService extends Service {
await this.saveRulesToFile()
if (rule.enabled) {
await this.ruleEngine.addRule(newRule)
this.startRuleTimer(newRule)
}
@@ -577,6 +472,7 @@ export class AutoScoreService extends Service {
await this.saveRulesToFile()
if (updatedRule.enabled) {
await this.ruleEngine.addRule(updatedRule)
this.startRuleTimer(updatedRule)
}
@@ -612,6 +508,7 @@ export class AutoScoreService extends Service {
}
if (enabled) {
await this.ruleEngine.addRule(rule)
this.startRuleTimer(rule)
}
+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)
}
}
+244
View File
@@ -0,0 +1,244 @@
export interface AutoScoreContext {
students: Array<{
id: number
name: string
tags: string[]
lastScoreTime: Date
}>
events: Array<{
id: number
student_name: string
delta: number
reason_content: string
created_at: Date
}>
rule: {
id: number
name: string
studentNames: string[]
triggers?: Array<{ event: string; value?: string; relation?: 'AND' | 'OR' }>
actions?: Array<{ event: string; value?: string; reason?: string }>
}
now: Date
}
export interface AutoScoreActionContext {
students: Array<{
id: number
name: string
tags: string[]
}>
params: Record<string, any>
}
export interface RuleConfig {
id: number
name: string
enabled: boolean
studentNames: string[]
triggers: Array<{
event: string
value?: string
relation?: 'AND' | 'OR'
}>
actions: Array<{
event: string
value?: string
reason?: string
}>
lastExecuted?: Date
}
export interface TriggerCheckResult {
matchedStudents: Array<{ id: number; name: string; tags?: string[]; lastScoreTime?: Date }>
}
export class AutoScoreRuleEngine {
private rules: Map<number, RuleConfig> = new Map()
async addRule(rule: RuleConfig): Promise<void> {
this.rules.set(rule.id, rule)
}
private checkIntervalTimeTrigger(
students: AutoScoreContext['students'],
value: string | undefined,
now: Date
): Set<number> {
const minutes = parseInt(value || '30', 10)
if (isNaN(minutes) || minutes <= 0) return new Set()
const intervalMs = minutes * 60 * 1000
const matchedIds = new Set<number>()
for (const student of students) {
if (!student.lastScoreTime) {
matchedIds.add(student.id)
continue
}
const timeSinceLastScore = now.getTime() - new Date(student.lastScoreTime).getTime()
if (timeSinceLastScore >= intervalMs) {
matchedIds.add(student.id)
}
}
return matchedIds
}
private checkStudentTagTrigger(
students: AutoScoreContext['students'],
value: string | undefined
): Set<number> {
const requiredTags = value
?.split(',')
.map((t) => t.trim())
.filter(Boolean) || []
if (requiredTags.length === 0) return new Set()
const matchedIds = new Set<number>()
for (const student of students) {
const studentTags = student.tags || []
if (requiredTags.some((tag) => studentTags.includes(tag))) {
matchedIds.add(student.id)
}
}
return matchedIds
}
async run(context: AutoScoreContext): Promise<
{
ruleId: number
matchedStudents: Array<{ id: number; name: string; tags?: string[] }>
actions: Array<{ event: string; value?: string; reason?: string }>
}[]
> {
const results: {
ruleId: number
matchedStudents: Array<{ id: number; name: string; tags?: string[] }>
actions: Array<{ event: string; value?: string; reason?: string }>
}[] = []
for (const [, rule] of this.rules) {
if (!rule.enabled) continue
const triggers = rule.triggers || []
if (triggers.length === 0) continue
const triggerResults: Set<number>[] = []
const relations: ('AND' | 'OR')[] = []
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i]
let matchedIds: Set<number>
switch (trigger.event) {
case 'interval_time_passed':
matchedIds = this.checkIntervalTimeTrigger(
context.students,
trigger.value,
context.now
)
break
case 'student_has_tag':
matchedIds = this.checkStudentTagTrigger(context.students, trigger.value)
break
default:
continue
}
triggerResults.push(matchedIds)
relations.push(trigger.relation || (i === 0 ? 'AND' : 'AND'))
}
if (triggerResults.length === 0) continue
let finalMatchedIds: Set<number>
const firstRelation = triggers[0]?.relation || 'AND'
if (firstRelation === 'OR') {
finalMatchedIds = new Set<number>()
for (const ids of triggerResults) {
for (const id of ids) {
finalMatchedIds.add(id)
}
}
} else {
finalMatchedIds = new Set(triggerResults[0])
for (let i = 1; i < triggerResults.length; i++) {
const relation = triggers[i]?.relation || 'AND'
if (relation === 'OR') {
for (const id of triggerResults[i]) {
finalMatchedIds.add(id)
}
} else {
const nextSet = new Set<number>()
for (const id of finalMatchedIds) {
if (triggerResults[i].has(id)) {
nextSet.add(id)
}
}
finalMatchedIds = nextSet
}
}
}
if (finalMatchedIds.size > 0) {
const matchedStudents = context.students.filter((s) => finalMatchedIds.has(s.id))
results.push({
ruleId: rule.id,
matchedStudents: matchedStudents.map((s) => ({
id: s.id,
name: s.name,
tags: s.tags
})),
actions: rule.actions || []
})
}
}
return results
}
async executeAction(
actionType: string,
students: Array<{ id: number; name: string; tags?: string[] }>,
params: any,
eventRepo: any
): Promise<void> {
switch (actionType) {
case 'add_score': {
const scoreValue = params.value ? parseInt(params.value, 10) : 0
const reason = params.reason || `自动化加分 - ${params.ruleName}`
for (const student of students) {
await eventRepo.create({
student_name: student.name,
reason_content: reason,
delta: scoreValue
})
}
break
}
case 'add_tag': {
const tagName = params.value
if (tagName) {
const studentRepo: any = params.studentRepo
for (const student of students) {
const currentTags = student.tags || []
if (!currentTags.includes(tagName)) {
await studentRepo.update(student.id, {
tags: [...currentTags, tagName]
})
}
}
}
break
}
default:
console.warn(`Unknown action event: ${actionType}`)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './AutoScoreRuleEngine' // interfaces and engine consolidated
export * from './triggers/IntervalTimeTrigger'
export * from './triggers/StudentTagTrigger'
@@ -0,0 +1,88 @@
import type { AutoScoreContext } from '../AutoScoreRuleEngine'
export interface IntervalTimeTriggerConfig {
intervalMinutes: number
}
export function createIntervalTimeTrigger() {
return {
id: 'interval_time_passed',
label: '间隔时间',
description: '每隔指定时间自动触发',
validate: (value: string): { valid: boolean; message?: string } => {
const minutes = parseInt(value, 10)
if (isNaN(minutes) || minutes <= 0) {
return { valid: false, message: '请输入有效的分钟数' }
}
return { valid: true }
},
calculateNextTime: (
value: string,
lastExecuted: Date | undefined,
now: Date
): { delayMs: number; nextExecuteTime: Date } => {
const minutes = parseInt(value, 10)
const intervalMs = minutes * 60 * 1000
let nextExecuteTime: Date
if (lastExecuted) {
nextExecuteTime = new Date(lastExecuted.getTime() + intervalMs)
} else {
nextExecuteTime = new Date(now.getTime() + intervalMs)
}
const delayMs = nextExecuteTime.getTime() - now.getTime()
return { delayMs, nextExecuteTime }
},
check: (
context: AutoScoreContext,
value: string
): {
shouldExecute: boolean
matchedStudents?: Array<{ id: number; name: string; tags: string[] }>
nextExecuteTime?: Date
delayMs?: number
} => {
const minutes = parseInt(value, 10)
if (isNaN(minutes) || minutes <= 0) {
return { shouldExecute: false }
}
const intervalMs = minutes * 60 * 1000
const now = context.now
const matchedStudents = context.students.filter((student) => {
if (!student.lastScoreTime) {
return true
}
const timeSinceLastScore = now.getTime() - student.lastScoreTime.getTime()
return timeSinceLastScore >= intervalMs
})
return {
shouldExecute: matchedStudents.length > 0,
matchedStudents
}
},
toRuleCondition: (value: string): any => {
const minutes = parseInt(value, 10)
const intervalMs = minutes * 60 * 1000
return {
all: [
{
fact: 'student',
path: '.lastScoreTime',
operator: 'lessThan',
value: new Date(Date.now() - intervalMs).toISOString()
}
]
}
}
}
}
@@ -0,0 +1,69 @@
import type { AutoScoreContext } from '../AutoScoreRuleEngine'
export interface StudentTagTriggerConfig {
requiredTags: string[]
matchMode: 'any' | 'all'
}
export function createStudentTagTrigger() {
return {
id: 'student_has_tag',
label: '学生标签',
description: '当学生拥有指定标签时触发(需要以间隔时间加分为前提)',
validate: (value: string): { valid: boolean; message?: string } => {
if (!value || value.trim() === '') {
return { valid: false, message: '请输入标签名称' }
}
return { valid: true }
},
check: (
context: AutoScoreContext,
value: string
): {
shouldExecute: boolean
matchedStudents?: Array<{ id: number; name: string; tags: string[] }>
} => {
const requiredTags = value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (requiredTags.length === 0) {
return { shouldExecute: false }
}
const matchedStudents = context.students.filter((student) => {
const studentTags = student.tags || []
return requiredTags.some((tag) => studentTags.includes(tag))
})
return {
shouldExecute: matchedStudents.length > 0,
matchedStudents
}
},
toRuleCondition: (value: string): any => {
const requiredTags = value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
return {
all: [
{
fact: 'student',
path: '.tags',
operator: 'containsAny',
value: requiredTags
}
]
}
},
requiresDependency: () => {
return 'interval_time_passed'
}
}
}
@@ -1,52 +0,0 @@
import type { TriggerLogic } from './types'
export const intervalTimeTrigger: TriggerLogic = {
eventName: 'interval_time_passed',
label: '根据间隔时间触发',
description: '当间隔时间到达时触发自动化',
validate: (value: string) => {
const minutes = parseInt(value, 10)
if (isNaN(minutes) || minutes <= 0) {
return { valid: false, message: '请输入有效的时间间隔(分钟)' }
}
return { valid: true }
},
calculateNextTime: (value: string, lastExecuted: Date | undefined, now: Date) => {
const intervalMinutes = parseInt(value, 10)
if (isNaN(intervalMinutes) || intervalMinutes <= 0) {
return { delayMs: 0, nextExecuteTime: now }
}
const intervalMs = intervalMinutes * 60 * 1000
let delayMs = intervalMs
if (lastExecuted) {
const timeSinceLastExecution = now.getTime() - lastExecuted.getTime()
delayMs = intervalMs - (timeSinceLastExecution % intervalMs)
if (timeSinceLastExecution >= intervalMs) {
delayMs = 0
}
}
const nextExecuteTime = new Date(now.getTime() + delayMs)
return { delayMs, nextExecuteTime }
},
check: (context, value) => {
const result = intervalTimeTrigger.calculateNextTime!(
value,
context.rule.triggers?.find((t) => t.event === 'interval_time_passed')
? context.now
: undefined,
context.now
)
return {
shouldExecute: result.delayMs === 0,
matchedStudents: context.students,
nextExecuteTime: result.nextExecuteTime,
delayMs: result.delayMs
}
}
}
-68
View File
@@ -1,68 +0,0 @@
import type { TriggerLogic } from './types'
export const randomTimeTrigger: TriggerLogic = {
eventName: 'random_time_reached',
label: '随机时间触发',
description: '当随机时间到达时触发自动化',
validate: (value: string) => {
if (!value) {
return { valid: true }
}
try {
const config = JSON.parse(value)
if (config.minHour !== undefined || config.maxHour !== undefined) {
const minHour = config.minHour ?? 0
const maxHour = config.maxHour ?? 23
if (minHour < 0 || minHour > 23 || maxHour < 0 || maxHour > 23) {
return { valid: false, message: '小时范围必须在0-23之间' }
}
if (minHour > maxHour) {
return { valid: false, message: '最小小时不能大于最大小时' }
}
}
return { valid: true }
} catch {
return { valid: false, message: '配置格式错误' }
}
},
calculateNextTime: (value: string, _lastExecuted: Date | undefined, now: Date) => {
let config = { minHour: 9, maxHour: 18 }
try {
if (value) {
const parsed = JSON.parse(value)
config = { ...config, ...parsed }
}
} catch (e) {
// swallow errors, use default config
console.debug('randomTimeTrigger calculateNextTime parse error', e)
}
const minHour = config.minHour ?? 0
const maxHour = config.maxHour ?? 23
const randomHour = Math.floor(Math.random() * (maxHour - minHour + 1)) + minHour
const randomMinute = Math.floor(Math.random() * 60)
const targetDate = new Date(now)
targetDate.setHours(randomHour, randomMinute, 0, 0)
if (targetDate.getTime() <= now.getTime()) {
targetDate.setDate(targetDate.getDate() + 1)
}
const delayMs = targetDate.getTime() - now.getTime()
return { delayMs, nextExecuteTime: targetDate }
},
check: (context, value) => {
const result = randomTimeTrigger.calculateNextTime!(value, undefined, context.now)
return {
shouldExecute: result.delayMs === 0,
matchedStudents: context.students,
nextExecuteTime: result.nextExecuteTime,
delayMs: result.delayMs
}
}
}
-30
View File
@@ -1,30 +0,0 @@
import type { TriggerLogic } from './types'
export const studentTagTrigger: TriggerLogic = {
eventName: 'student_tag_matched',
label: '按照学生标签触发',
description: '当学生标签匹配时触发自动化',
validate: (value: string) => {
if (!value || value.trim() === '') {
return { valid: false, message: '请输入标签名称' }
}
return { valid: true }
},
check: (context, value) => {
const tagName = value.trim().toLowerCase()
const matchedStudents = context.students.filter((student: any) => {
if (student.tags && Array.isArray(student.tags)) {
return student.tags.some((tag: string) => tag.toLowerCase() === tagName)
}
return false
})
return {
shouldExecute: matchedStudents.length > 0,
matchedStudents,
nextExecuteTime: context.now
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import type { TriggerLogic } from './types'
import { intervalTimeTrigger } from './IntervalTimeTrigger'
import { studentTagTrigger } from './StudentTagTrigger'
import { randomTimeTrigger } from './RandomTimeTrigger'
export * from './types'
export { intervalTimeTrigger, studentTagTrigger, randomTimeTrigger }
export const allTriggerLogics: TriggerLogic[] = [
intervalTimeTrigger,
studentTagTrigger,
randomTimeTrigger
]
export function getTriggerLogic(eventName: string): TriggerLogic | undefined {
const direct = allTriggerLogics.find((t) => t.eventName === eventName)
if (direct) return direct
const aliasMap: Record<string, string> = {
random_time: 'random_time_reached',
student_tag_added: 'student_tag_matched'
}
const mapped = aliasMap[eventName]
if (mapped) {
return allTriggerLogics.find((t) => t.eventName === mapped)
}
return undefined
}
-32
View File
@@ -1,32 +0,0 @@
export interface TriggerContext {
students: any[]
events: any[]
rule: {
id: number
name: string
studentNames: string[]
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
actions?: { event: string; value?: string; reason?: string }[]
}
now: Date
}
export interface TriggerResult {
shouldExecute: boolean
matchedStudents?: any[]
nextExecuteTime?: Date
delayMs?: number
}
export interface TriggerLogic {
eventName: string
label: string
description: string
validate: (value: string) => { valid: boolean; message?: string }
calculateNextTime?: (
value: string,
lastExecuted: Date | undefined,
now: Date
) => { delayMs: number; nextExecuteTime: Date }
check?: (context: TriggerContext, value: string) => TriggerResult
}