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