mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 间隔时间加分可以按照多少天、月、分钟后加分
fix: 修复若干问题 下雨了好害怕我靠
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::Deserialize;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use sqlx::{Column, Row, SqlitePool};
|
||||
use std::collections::HashSet;
|
||||
@@ -155,7 +155,10 @@ async fn get_board_configs_raw(conn: &DatabaseConnection) -> Result<Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_board_configs_raw(conn: &DatabaseConnection, config_json: &str) -> Result<(), String> {
|
||||
async fn upsert_board_configs_raw(
|
||||
conn: &DatabaseConnection,
|
||||
config_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let config_escaped = escape_sql(config_json);
|
||||
let now_escaped = escape_sql(&now_iso());
|
||||
let sql = format!(
|
||||
|
||||
@@ -1150,7 +1150,10 @@ pub async fn db_switch_connection(
|
||||
let logger = state_guard.logger.read();
|
||||
logger.log(
|
||||
LogLevel::Info,
|
||||
&format!("Database switch requested, connection_string length: {}", connection_string.len()),
|
||||
&format!(
|
||||
"Database switch requested, connection_string length: {}",
|
||||
connection_string.len()
|
||||
),
|
||||
Some("database"),
|
||||
None,
|
||||
);
|
||||
@@ -1225,9 +1228,7 @@ pub async fn db_switch_connection(
|
||||
connection_string
|
||||
};
|
||||
|
||||
let conn = create_sqlite_connection(&path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let conn = create_sqlite_connection(&path).await.map_err(|e| {
|
||||
logger.log(
|
||||
LogLevel::Error,
|
||||
&format!("SQLite connection failed: {}", e),
|
||||
|
||||
@@ -313,7 +313,9 @@ async fn post_banyou_action<T: DeserializeOwned>(
|
||||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let url = format!("https://care.seewo.com/app/apis.json?action={action}×tamp={timestamp}&isAjax=1");
|
||||
let url = format!(
|
||||
"https://care.seewo.com/app/apis.json?action={action}×tamp={timestamp}&isAjax=1"
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"action": action,
|
||||
"params": params,
|
||||
@@ -371,7 +373,11 @@ async fn post_banyou_action<T: DeserializeOwned>(
|
||||
"body_preview": first_n_chars(&body, 500),
|
||||
})),
|
||||
);
|
||||
return Err(format!("班优接口请求失败({} HTTP {})", action, status.as_u16()));
|
||||
return Err(format!(
|
||||
"班优接口请求失败({} HTTP {})",
|
||||
action,
|
||||
status.as_u16()
|
||||
));
|
||||
}
|
||||
|
||||
let parsed: BanYouApiResponse<T> = serde_json::from_str(&body).map_err(|e| {
|
||||
@@ -993,7 +999,9 @@ pub async fn student_fetch_banyou_classroom_detail(
|
||||
(Vec::new(), None)
|
||||
};
|
||||
|
||||
let final_team_plan_id = params.team_plan_id.or_else(|| team_plans.first().map(|p| p.team_plan_id));
|
||||
let final_team_plan_id = params
|
||||
.team_plan_id
|
||||
.or_else(|| team_plans.first().map(|p| p.team_plan_id));
|
||||
|
||||
let group_data = if let Some(team_plan_id) = final_team_plan_id {
|
||||
match post_banyou_action::<BanYouGroupFetchData>(
|
||||
@@ -1032,7 +1040,10 @@ pub async fn student_fetch_banyou_classroom_detail(
|
||||
let detail = BanYouClassroomDetailData {
|
||||
medals,
|
||||
students: students_data.students,
|
||||
teams: group_data.as_ref().map(|d| d.teams.clone()).unwrap_or_default(),
|
||||
teams: group_data
|
||||
.as_ref()
|
||||
.map(|d| d.teams.clone())
|
||||
.unwrap_or_default(),
|
||||
ungrouped_students: group_data
|
||||
.as_ref()
|
||||
.map(|d| d.students.clone())
|
||||
|
||||
@@ -292,9 +292,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
*db_guard = Some(active_conn);
|
||||
}
|
||||
|
||||
state_guard.initialize().await.map_err(|e| {
|
||||
format!("Failed to initialize app state: {}", e)
|
||||
})?;
|
||||
state_guard
|
||||
.initialize()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to initialize app state: {}", e))?;
|
||||
|
||||
Ok::<_, Box<dyn std::error::Error>>(())
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Months, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
@@ -15,6 +15,20 @@ pub struct AutoScoreAction {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum IntervalUnit {
|
||||
Minute,
|
||||
Day,
|
||||
Month,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct IntervalTriggerValue {
|
||||
amount: i64,
|
||||
unit: IntervalUnit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoScoreRule {
|
||||
pub id: i32,
|
||||
@@ -171,25 +185,19 @@ impl AutoScoreService {
|
||||
|
||||
for trigger in &rule.triggers {
|
||||
if trigger.event == "interval_time_passed" {
|
||||
let minutes = trigger
|
||||
.value
|
||||
let interval = parse_interval_trigger_value(trigger.value.as_ref());
|
||||
let base_time = rule
|
||||
.last_executed
|
||||
.as_ref()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(30);
|
||||
let interval_ms = minutes * 60 * 1000;
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or(now);
|
||||
|
||||
if let Some(last_executed_str) = &rule.last_executed {
|
||||
if let Ok(last_executed) = DateTime::parse_from_rfc3339(last_executed_str) {
|
||||
let last_executed_utc: DateTime<Utc> = last_executed.with_timezone(&Utc);
|
||||
let next_execute_time =
|
||||
last_executed_utc + chrono::Duration::milliseconds(interval_ms);
|
||||
let next_execute_time = add_interval_to_time(base_time, &interval)?;
|
||||
let delay_ms = (next_execute_time - now).num_milliseconds();
|
||||
return Some(delay_ms.max(0));
|
||||
}
|
||||
}
|
||||
return Some(interval_ms);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -197,3 +205,43 @@ impl AutoScoreService {
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_interval_trigger_value(value: Option<&String>) -> IntervalTriggerValue {
|
||||
if let Some(raw_value) = value {
|
||||
if let Ok(minutes) = raw_value.parse::<i64>() {
|
||||
if minutes > 0 {
|
||||
return IntervalTriggerValue {
|
||||
amount: minutes,
|
||||
unit: IntervalUnit::Minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(parsed_value) = serde_json::from_str::<IntervalTriggerValue>(raw_value) {
|
||||
if parsed_value.amount > 0 {
|
||||
return parsed_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IntervalTriggerValue {
|
||||
amount: 30,
|
||||
unit: IntervalUnit::Minute,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_interval_to_time(
|
||||
base_time: DateTime<Utc>,
|
||||
interval: &IntervalTriggerValue,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
match interval.unit {
|
||||
IntervalUnit::Minute => {
|
||||
base_time.checked_add_signed(chrono::Duration::minutes(interval.amount))
|
||||
}
|
||||
IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(interval.amount)),
|
||||
IntervalUnit::Month => {
|
||||
let months = u32::try_from(interval.amount).ok()?;
|
||||
base_time.checked_add_months(Months::new(months))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +431,7 @@ impl SettingsService {
|
||||
"settings",
|
||||
];
|
||||
return items.iter().all(|item| {
|
||||
item.as_str()
|
||||
.map(|s| allowed.contains(&s))
|
||||
.unwrap_or(false)
|
||||
item.as_str().map(|s| allowed.contains(&s)).unwrap_or(false)
|
||||
});
|
||||
}
|
||||
false
|
||||
|
||||
@@ -92,6 +92,9 @@ select {
|
||||
|
||||
.ss-sidebar .ant-menu-item {
|
||||
color: var(--ss-sidebar-text, var(--ss-text-main));
|
||||
height: 40px !important;
|
||||
line-height: 42px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.ss-sidebar .ant-menu-item-selected {
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
Tag,
|
||||
message,
|
||||
} from "antd"
|
||||
import { Utils as QbUtils, type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { fetchAllTags } from "./TagEditorDialog"
|
||||
import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor"
|
||||
import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder"
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
triggersToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreRule,
|
||||
type AutoScoreTagOption,
|
||||
} from "./AutoScoreManagerPage/AutoScoreUtils"
|
||||
|
||||
interface StudentItem {
|
||||
@@ -37,6 +39,11 @@ interface StudentItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TagItem {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RuleFormValues {
|
||||
name?: string
|
||||
studentNames?: string[]
|
||||
@@ -50,10 +57,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const { t, i18n } = useTranslation()
|
||||
const [form] = Form.useForm<RuleFormValues>()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [tags, setTags] = useState<TagItem[]>([])
|
||||
|
||||
const tagOptions = useMemo<AutoScoreTagOption[]>(
|
||||
() =>
|
||||
tags.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
})),
|
||||
[tags]
|
||||
)
|
||||
|
||||
const triggerConfig = useMemo(
|
||||
() => createTriggerQueryConfig(t),
|
||||
[i18n.resolvedLanguage, i18n.language]
|
||||
() => createTriggerQueryConfig(t, tagOptions),
|
||||
[i18n.resolvedLanguage, i18n.language, tagOptions]
|
||||
)
|
||||
const [triggerTree, setTriggerTree] = useState<ImmutableTree>(() =>
|
||||
createEmptyTriggerTree(triggerConfig)
|
||||
@@ -67,10 +84,6 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerTree((prevTree) => QbUtils.checkTree(prevTree, triggerConfig))
|
||||
}, [triggerConfig])
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
|
||||
if (currentPage > maxPage) {
|
||||
@@ -103,6 +116,19 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
if (!canEdit) return
|
||||
|
||||
try {
|
||||
const tagList = await fetchAllTags()
|
||||
if (Array.isArray(tagList)) {
|
||||
setTags(tagList)
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRules = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
@@ -124,6 +150,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
fetchTags().catch(() => void 0)
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
}, [canEdit])
|
||||
@@ -387,10 +414,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
config={triggerConfig}
|
||||
value={triggerTree}
|
||||
canEdit={canEdit}
|
||||
onChange={(nextTree, config) => setTriggerTree(QbUtils.checkTree(nextTree, config))}
|
||||
onChange={(nextTree) => setTriggerTree(nextTree)}
|
||||
/>
|
||||
|
||||
<ActionEditor value={actionDrafts} canEdit={canEdit} onChange={setActionDrafts} />
|
||||
<ActionEditor
|
||||
value={actionDrafts}
|
||||
tagOptions={tagOptions}
|
||||
canEdit={canEdit}
|
||||
onChange={setActionDrafts}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
|
||||
<Button type="primary" loading={saving} disabled={!canEdit} onClick={() => handleSubmit().catch(() => void 0)}>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { Button, Card, Input, InputNumber, Select, Space } from "antd"
|
||||
import { Button, Card, InputNumber, Select, Space } from "antd"
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ActionDraft, ActionEvent } from "./AutoScoreUtils"
|
||||
import type { ActionDraft, ActionEvent, AutoScoreTagOption } from "./AutoScoreUtils"
|
||||
import { createDefaultActionDraft } from "./AutoScoreUtils"
|
||||
|
||||
interface ActionEditorProps {
|
||||
value: ActionDraft[]
|
||||
tagOptions: AutoScoreTagOption[]
|
||||
canEdit: boolean
|
||||
onChange: (nextDrafts: ActionDraft[]) => void
|
||||
}
|
||||
|
||||
export const ActionEditor: React.FC<ActionEditorProps> = ({ value, canEdit, onChange }) => {
|
||||
export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
value,
|
||||
tagOptions,
|
||||
canEdit,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const actionOptions = useMemo(
|
||||
@@ -22,6 +28,27 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({ value, canEdit, onCh
|
||||
[t]
|
||||
)
|
||||
|
||||
const mergedTagOptions = useMemo(() => {
|
||||
const optionMap = new Map(tagOptions.map((option) => [option.value, option]))
|
||||
|
||||
for (const action of value) {
|
||||
if (action.event !== "add_tag") continue
|
||||
const tagValues = Array.isArray(action.value)
|
||||
? action.value
|
||||
: action.value
|
||||
? [action.value]
|
||||
: []
|
||||
|
||||
for (const tagValue of tagValues) {
|
||||
if (!optionMap.has(tagValue)) {
|
||||
optionMap.set(tagValue, { label: tagValue, value: tagValue })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(optionMap.values())
|
||||
}, [tagOptions, value])
|
||||
|
||||
const updateAction = (id: string, patch: Partial<Omit<ActionDraft, "id">>) => {
|
||||
onChange(value.map((item) => (item.id === id ? { ...item, ...patch } : item)))
|
||||
}
|
||||
@@ -51,7 +78,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({ value, canEdit, onCh
|
||||
onChange={(event: ActionEvent) =>
|
||||
updateAction(action.id, {
|
||||
event,
|
||||
value: event === "add_score" ? "1" : "",
|
||||
value: event === "add_score" ? "1" : [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -66,12 +93,16 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({ value, canEdit, onCh
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
style={{ minWidth: 220 }}
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ minWidth: 260 }}
|
||||
placeholder={t("autoScore.tagNamePlaceholder")}
|
||||
value={action.value}
|
||||
value={Array.isArray(action.value) ? action.value : action.value ? [action.value] : []}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => updateAction(action.id, { value: event.target.value })}
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type JsonRule,
|
||||
} from "@react-awesome-query-builder/antd"
|
||||
import type { TFunction } from "i18next"
|
||||
import { IntervalValueWidget, parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueWidget"
|
||||
|
||||
export interface AutoScoreTrigger {
|
||||
event: string
|
||||
@@ -31,17 +32,25 @@ export interface AutoScoreRule {
|
||||
|
||||
export type ActionEvent = "add_score" | "add_tag"
|
||||
|
||||
export interface AutoScoreTagOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ActionDraft {
|
||||
id: string
|
||||
event: ActionEvent
|
||||
value: string
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export type ActionDraftError = "score_required" | "tag_required" | null
|
||||
|
||||
const TRIGGER_FIELD_INTERVAL = "interval_minutes"
|
||||
const TRIGGER_FIELD_TAG = "student_tag"
|
||||
const TRIGGER_TYPE_INTERVAL = "interval_duration"
|
||||
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
|
||||
const OP_EQUAL = "equal"
|
||||
const OP_MULTISELECT_CONTAINS = "multiselect_contains"
|
||||
|
||||
const buildEmptyGroup = (): JsonGroup => ({
|
||||
id: QbUtils.uuid(),
|
||||
@@ -66,31 +75,71 @@ const toStringValue = (value: unknown): string => {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const normalizeTagValues = (values: unknown[]): string[] => {
|
||||
const normalized = values
|
||||
.map((value) => toStringValue(value).trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return Array.from(new Set(normalized))
|
||||
}
|
||||
|
||||
const parseTagValues = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeTagValues(value)
|
||||
}
|
||||
|
||||
const text = toStringValue(value).trim()
|
||||
if (!text) return []
|
||||
|
||||
if (text.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (Array.isArray(parsed)) {
|
||||
return normalizeTagValues(parsed)
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return [text]
|
||||
}
|
||||
|
||||
const stringifyTagValues = (values: string[]): string | null => {
|
||||
const normalized = normalizeTagValues(values)
|
||||
if (normalized.length === 0) return null
|
||||
if (normalized.length === 1) return normalized[0]
|
||||
return JSON.stringify(normalized)
|
||||
}
|
||||
|
||||
const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
|
||||
if (trigger.event === "interval_time_passed") {
|
||||
const minutes = toFiniteNumber(trigger.value)
|
||||
if (!minutes || minutes <= 0) return null
|
||||
const intervalValue = parseIntervalTriggerValue(trigger.value)
|
||||
const serializedIntervalValue = intervalValue
|
||||
? stringifyIntervalTriggerValue(intervalValue)
|
||||
: null
|
||||
if (!serializedIntervalValue) return null
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: TRIGGER_FIELD_INTERVAL,
|
||||
operator: OP_EQUAL,
|
||||
value: [Math.floor(minutes)],
|
||||
value: [serializedIntervalValue],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.event === "student_has_tag") {
|
||||
const tagName = toStringValue(trigger.value).trim()
|
||||
if (!tagName) return null
|
||||
const tagNames = parseTagValues(trigger.value)
|
||||
if (tagNames.length === 0) return null
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: TRIGGER_FIELD_TAG,
|
||||
operator: OP_EQUAL,
|
||||
value: [tagName],
|
||||
operator: OP_MULTISELECT_CONTAINS,
|
||||
value: [tagNames],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -103,20 +152,24 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
|
||||
const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined
|
||||
|
||||
if (field === TRIGGER_FIELD_INTERVAL) {
|
||||
const minutes = toFiniteNumber(value)
|
||||
if (!minutes || minutes <= 0) return null
|
||||
const intervalValue = parseIntervalTriggerValue(value)
|
||||
const serializedIntervalValue = intervalValue
|
||||
? stringifyIntervalTriggerValue(intervalValue)
|
||||
: null
|
||||
if (!serializedIntervalValue) return null
|
||||
return {
|
||||
event: "interval_time_passed",
|
||||
value: String(Math.floor(minutes)),
|
||||
value: serializedIntervalValue,
|
||||
}
|
||||
}
|
||||
|
||||
if (field === TRIGGER_FIELD_TAG) {
|
||||
const tagName = toStringValue(value).trim()
|
||||
if (!tagName) return null
|
||||
const tagNames = parseTagValues(value)
|
||||
const serializedValue = stringifyTagValues(tagNames)
|
||||
if (!serializedValue) return null
|
||||
return {
|
||||
event: "student_has_tag",
|
||||
value: tagName,
|
||||
value: serializedValue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,24 +192,61 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
|
||||
return collected
|
||||
}
|
||||
|
||||
export const createTriggerQueryConfig = (t: TFunction): Config =>
|
||||
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
|
||||
({
|
||||
...AntdConfig,
|
||||
widgets: {
|
||||
...AntdConfig.widgets,
|
||||
[TRIGGER_WIDGET_INTERVAL]: {
|
||||
...AntdConfig.widgets.text,
|
||||
type: "text",
|
||||
jsType: "string",
|
||||
hideOperator: true,
|
||||
factory: IntervalValueWidget,
|
||||
valuePlaceholder: t("autoScore.intervalAmountPlaceholder"),
|
||||
validateValue: (value) => {
|
||||
const rawValue = toStringValue(value).trim()
|
||||
if (!rawValue) return true
|
||||
return parseIntervalTriggerValue(rawValue) !== null
|
||||
},
|
||||
},
|
||||
},
|
||||
types: {
|
||||
...AntdConfig.types,
|
||||
[TRIGGER_TYPE_INTERVAL]: {
|
||||
...AntdConfig.types.text,
|
||||
defaultOperator: OP_EQUAL,
|
||||
mainWidget: TRIGGER_WIDGET_INTERVAL,
|
||||
widgets: {
|
||||
[TRIGGER_WIDGET_INTERVAL]: {
|
||||
operators: [OP_EQUAL],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
[TRIGGER_FIELD_INTERVAL]: {
|
||||
label: t("autoScore.triggerIntervalTime"),
|
||||
type: "number",
|
||||
type: TRIGGER_TYPE_INTERVAL,
|
||||
operators: [OP_EQUAL],
|
||||
valueSources: ["value"],
|
||||
preferWidgets: ["number"],
|
||||
fieldSettings: { min: 1 },
|
||||
preferWidgets: [TRIGGER_WIDGET_INTERVAL],
|
||||
},
|
||||
[TRIGGER_FIELD_TAG]: {
|
||||
label: t("autoScore.triggerStudentTag"),
|
||||
type: "text",
|
||||
operators: [OP_EQUAL],
|
||||
type: "multiselect",
|
||||
defaultOperator: OP_MULTISELECT_CONTAINS,
|
||||
operators: [OP_MULTISELECT_CONTAINS],
|
||||
valueSources: ["value"],
|
||||
preferWidgets: ["text"],
|
||||
preferWidgets: ["multiselect"],
|
||||
fieldSettings: {
|
||||
listValues: tagOptions.map((tag) => ({
|
||||
value: tag.value,
|
||||
title: tag.label,
|
||||
})),
|
||||
allowCustomValues: true,
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
@@ -229,7 +319,8 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
event: action.event,
|
||||
value: toStringValue(action.value),
|
||||
value:
|
||||
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -243,7 +334,7 @@ export const actionDraftsToPayload = (
|
||||
const actions: AutoScoreAction[] = []
|
||||
for (const draft of drafts) {
|
||||
if (draft.event === "add_score") {
|
||||
const score = toFiniteNumber(draft.value)
|
||||
const score = Array.isArray(draft.value) ? null : toFiniteNumber(draft.value)
|
||||
if (!score || score === 0) {
|
||||
return { actions: [], error: "score_required" }
|
||||
}
|
||||
@@ -251,11 +342,11 @@ export const actionDraftsToPayload = (
|
||||
continue
|
||||
}
|
||||
|
||||
const tagName = draft.value.trim()
|
||||
if (!tagName) {
|
||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||
if (!serializedTagValue) {
|
||||
return { actions: [], error: "tag_required" }
|
||||
}
|
||||
actions.push({ event: draft.event, value: tagName })
|
||||
actions.push({ event: draft.event, value: serializedTagValue })
|
||||
}
|
||||
|
||||
return { actions, error: null }
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export type IntervalUnit = "minute" | "day" | "month"
|
||||
|
||||
export interface IntervalValue {
|
||||
amount: number
|
||||
unit: IntervalUnit
|
||||
}
|
||||
|
||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
||||
|
||||
const normalizePositiveInteger = (value: unknown): number | null => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const normalized = Math.floor(value)
|
||||
return normalized > 0 ? normalized : null
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
const normalized = Math.floor(parsed)
|
||||
return normalized > 0 ? normalized : null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const isIntervalUnit = (value: unknown): value is IntervalUnit =>
|
||||
value === "minute" || value === "day" || value === "month"
|
||||
|
||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
||||
amount: 1,
|
||||
unit: DEFAULT_INTERVAL_UNIT,
|
||||
})
|
||||
|
||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const amount = normalizePositiveInteger((value as IntervalValue).amount)
|
||||
const unit = (value as IntervalValue).unit
|
||||
if (amount && isIntervalUnit(unit)) {
|
||||
return { amount, unit }
|
||||
}
|
||||
}
|
||||
|
||||
const text = value === null || value === undefined ? "" : String(value).trim()
|
||||
if (!text) return null
|
||||
|
||||
const legacyMinutes = normalizePositiveInteger(text)
|
||||
if (legacyMinutes !== null && !text.startsWith("{")) {
|
||||
return { amount: legacyMinutes, unit: "minute" }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<IntervalValue>
|
||||
const amount = normalizePositiveInteger(parsed.amount)
|
||||
if (amount && isIntervalUnit(parsed.unit)) {
|
||||
return { amount, unit: parsed.unit }
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => {
|
||||
const amount = normalizePositiveInteger(value.amount)
|
||||
if (!amount || !isIntervalUnit(value.unit)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (value.unit === "minute") {
|
||||
return String(amount)
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
amount,
|
||||
unit: value.unit,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { InputNumber, Select } from "antd"
|
||||
import type { WidgetProps } from "@react-awesome-query-builder/antd"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export type IntervalUnit = "month" | "day" | "minute"
|
||||
|
||||
export interface IntervalValue {
|
||||
amount: number
|
||||
unit: IntervalUnit
|
||||
}
|
||||
|
||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
||||
|
||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
||||
amount: 1,
|
||||
unit: DEFAULT_INTERVAL_UNIT,
|
||||
})
|
||||
|
||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
const str = String(value).trim()
|
||||
if (!str) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
typeof parsed.amount === "number" &&
|
||||
typeof parsed.unit === "string"
|
||||
) {
|
||||
const validUnits: IntervalUnit[] = ["month", "day", "minute"]
|
||||
if (validUnits.includes(parsed.unit as IntervalUnit)) {
|
||||
return {
|
||||
amount: Math.max(1, Math.floor(parsed.amount)),
|
||||
unit: parsed.unit as IntervalUnit,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string => {
|
||||
return JSON.stringify({
|
||||
amount: value.amount,
|
||||
unit: value.unit,
|
||||
})
|
||||
}
|
||||
|
||||
export const IntervalValueWidget: React.FC<WidgetProps> = ({
|
||||
value,
|
||||
setValue,
|
||||
readonly,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const parsedValue = parseIntervalTriggerValue(value)
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() => [
|
||||
{ label: t("autoScore.intervalUnitMonth"), value: "month" },
|
||||
{ label: t("autoScore.intervalUnitDay"), value: "day" },
|
||||
{ label: t("autoScore.intervalUnitMinute"), value: "minute" },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleAmountChange = (nextAmount: number | null) => {
|
||||
if (nextAmount === null) {
|
||||
setValue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const serializedValue = stringifyIntervalTriggerValue({
|
||||
amount: nextAmount,
|
||||
unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT,
|
||||
})
|
||||
setValue(serializedValue)
|
||||
}
|
||||
|
||||
const handleUnitChange = (nextUnit: IntervalUnit) => {
|
||||
const baseValue = parsedValue ?? getDefaultIntervalValue()
|
||||
const serializedValue = stringifyIntervalTriggerValue({
|
||||
amount: baseValue.amount,
|
||||
unit: nextUnit,
|
||||
})
|
||||
setValue(serializedValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, minWidth: 280 }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={parsedValue?.amount}
|
||||
placeholder={placeholder || t("autoScore.intervalAmountPlaceholder")}
|
||||
onChange={handleAmountChange}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
value={parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT}
|
||||
options={unitOptions}
|
||||
onChange={handleUnitChange}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntervalValueWidget
|
||||
@@ -803,6 +803,10 @@
|
||||
"triggerStudentTag": "Student Tag",
|
||||
"actionAddScore": "Add Score",
|
||||
"actionAddTag": "Add Tag",
|
||||
"intervalAmountPlaceholder": "Enter interval amount",
|
||||
"intervalUnitMonth": "month(s) later",
|
||||
"intervalUnitDay": "day(s) later",
|
||||
"intervalUnitMinute": "minute(s) later",
|
||||
"minutesPlaceholder": "Enter minutes",
|
||||
"minutes": "minutes",
|
||||
"tagsPlaceholder": "Enter tag name",
|
||||
|
||||
@@ -801,6 +801,10 @@
|
||||
"triggerStudentTag": "学生标签",
|
||||
"actionAddScore": "加分",
|
||||
"actionAddTag": "添加标签",
|
||||
"intervalAmountPlaceholder": "请输入间隔数量",
|
||||
"intervalUnitMonth": "个月后",
|
||||
"intervalUnitDay": "天后",
|
||||
"intervalUnitMinute": "分钟后",
|
||||
"minutesPlaceholder": "请输入分钟数",
|
||||
"minutes": "分钟",
|
||||
"tagsPlaceholder": "请输入标签名称",
|
||||
|
||||
Reference in New Issue
Block a user