feat: 间隔时间加分可以按照多少天、月、分钟后加分

fix: 修复若干问题
下雨了好害怕我靠
This commit is contained in:
NanGua-QWQ
2026-03-29 18:17:54 +08:00
parent a930e4843c
commit e4d9ee0c85
14 changed files with 507 additions and 83 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::Deserialize;
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
use serde::Deserialize;
use serde_json::{Map, Value as JsonValue}; use serde_json::{Map, Value as JsonValue};
use sqlx::{Column, Row, SqlitePool}; use sqlx::{Column, Row, SqlitePool};
use std::collections::HashSet; 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 config_escaped = escape_sql(config_json);
let now_escaped = escape_sql(&now_iso()); let now_escaped = escape_sql(&now_iso());
let sql = format!( let sql = format!(
+13 -12
View File
@@ -1150,7 +1150,10 @@ pub async fn db_switch_connection(
let logger = state_guard.logger.read(); let logger = state_guard.logger.read();
logger.log( logger.log(
LogLevel::Info, LogLevel::Info,
&format!("Database switch requested, connection_string length: {}", connection_string.len()), &format!(
"Database switch requested, connection_string length: {}",
connection_string.len()
),
Some("database"), Some("database"),
None, None,
); );
@@ -1225,17 +1228,15 @@ pub async fn db_switch_connection(
connection_string connection_string
}; };
let conn = create_sqlite_connection(&path) let conn = create_sqlite_connection(&path).await.map_err(|e| {
.await logger.log(
.map_err(|e| { LogLevel::Error,
logger.log( &format!("SQLite connection failed: {}", e),
LogLevel::Error, Some("database"),
&format!("SQLite connection failed: {}", e), None,
Some("database"), );
None, e.to_string()
); })?;
e.to_string()
})?;
run_migration(&conn, DatabaseType::SQLite) run_migration(&conn, DatabaseType::SQLite)
.await .await
.map_err(|e| { .map_err(|e| {
+15 -4
View File
@@ -313,7 +313,9 @@ async fn post_banyou_action<T: DeserializeOwned>(
params: serde_json::Value, params: serde_json::Value,
) -> Result<T, String> { ) -> Result<T, String> {
let timestamp = chrono::Utc::now().timestamp_millis(); let timestamp = chrono::Utc::now().timestamp_millis();
let url = format!("https://care.seewo.com/app/apis.json?action={action}&timestamp={timestamp}&isAjax=1"); let url = format!(
"https://care.seewo.com/app/apis.json?action={action}&timestamp={timestamp}&isAjax=1"
);
let payload = serde_json::json!({ let payload = serde_json::json!({
"action": action, "action": action,
"params": params, "params": params,
@@ -371,7 +373,11 @@ async fn post_banyou_action<T: DeserializeOwned>(
"body_preview": first_n_chars(&body, 500), "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| { 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) (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 { let group_data = if let Some(team_plan_id) = final_team_plan_id {
match post_banyou_action::<BanYouGroupFetchData>( match post_banyou_action::<BanYouGroupFetchData>(
@@ -1032,7 +1040,10 @@ pub async fn student_fetch_banyou_classroom_detail(
let detail = BanYouClassroomDetailData { let detail = BanYouClassroomDetailData {
medals, medals,
students: students_data.students, 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 ungrouped_students: group_data
.as_ref() .as_ref()
.map(|d| d.students.clone()) .map(|d| d.students.clone())
+4 -3
View File
@@ -292,9 +292,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
*db_guard = Some(active_conn); *db_guard = Some(active_conn);
} }
state_guard.initialize().await.map_err(|e| { state_guard
format!("Failed to initialize app state: {}", e) .initialize()
})?; .await
.map_err(|e| format!("Failed to initialize app state: {}", e))?;
Ok::<_, Box<dyn std::error::Error>>(()) Ok::<_, Box<dyn std::error::Error>>(())
}); });
+64 -16
View File
@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Months, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@@ -15,6 +15,20 @@ pub struct AutoScoreAction {
pub value: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoScoreRule { pub struct AutoScoreRule {
pub id: i32, pub id: i32,
@@ -171,23 +185,17 @@ impl AutoScoreService {
for trigger in &rule.triggers { for trigger in &rule.triggers {
if trigger.event == "interval_time_passed" { if trigger.event == "interval_time_passed" {
let minutes = trigger let interval = parse_interval_trigger_value(trigger.value.as_ref());
.value let base_time = rule
.last_executed
.as_ref() .as_ref()
.and_then(|v| v.parse::<i64>().ok()) .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.unwrap_or(30); .map(|value| value.with_timezone(&Utc))
let interval_ms = minutes * 60 * 1000; .unwrap_or(now);
if let Some(last_executed_str) = &rule.last_executed { let next_execute_time = add_interval_to_time(base_time, &interval)?;
if let Ok(last_executed) = DateTime::parse_from_rfc3339(last_executed_str) { let delay_ms = (next_execute_time - now).num_milliseconds();
let last_executed_utc: DateTime<Utc> = last_executed.with_timezone(&Utc); return Some(delay_ms.max(0));
let next_execute_time =
last_executed_utc + chrono::Duration::milliseconds(interval_ms);
let delay_ms = (next_execute_time - now).num_milliseconds();
return Some(delay_ms.max(0));
}
}
return Some(interval_ms);
} }
} }
None None
@@ -197,3 +205,43 @@ impl AutoScoreService {
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules); 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))
}
}
}
+1 -3
View File
@@ -431,9 +431,7 @@ impl SettingsService {
"settings", "settings",
]; ];
return items.iter().all(|item| { return items.iter().all(|item| {
item.as_str() item.as_str().map(|s| allowed.contains(&s)).unwrap_or(false)
.map(|s| allowed.contains(&s))
.unwrap_or(false)
}); });
} }
false false
+3
View File
@@ -92,6 +92,9 @@ select {
.ss-sidebar .ant-menu-item { .ss-sidebar .ant-menu-item {
color: var(--ss-sidebar-text, var(--ss-text-main)); 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 { .ss-sidebar .ant-menu-item-selected {
+41 -9
View File
@@ -14,9 +14,10 @@ import {
Tag, Tag,
message, message,
} from "antd" } 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 type { ColumnsType } from "antd/es/table"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { fetchAllTags } from "./TagEditorDialog"
import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor" import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor"
import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder" import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder"
import { import {
@@ -30,6 +31,7 @@ import {
triggersToQueryTree, triggersToQueryTree,
type ActionDraft, type ActionDraft,
type AutoScoreRule, type AutoScoreRule,
type AutoScoreTagOption,
} from "./AutoScoreManagerPage/AutoScoreUtils" } from "./AutoScoreManagerPage/AutoScoreUtils"
interface StudentItem { interface StudentItem {
@@ -37,6 +39,11 @@ interface StudentItem {
name: string name: string
} }
interface TagItem {
id: number
name: string
}
interface RuleFormValues { interface RuleFormValues {
name?: string name?: string
studentNames?: string[] studentNames?: string[]
@@ -50,10 +57,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [form] = Form.useForm<RuleFormValues>() const [form] = Form.useForm<RuleFormValues>()
const [messageApi, contextHolder] = message.useMessage() 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( const triggerConfig = useMemo(
() => createTriggerQueryConfig(t), () => createTriggerQueryConfig(t, tagOptions),
[i18n.resolvedLanguage, i18n.language] [i18n.resolvedLanguage, i18n.language, tagOptions]
) )
const [triggerTree, setTriggerTree] = useState<ImmutableTree>(() => const [triggerTree, setTriggerTree] = useState<ImmutableTree>(() =>
createEmptyTriggerTree(triggerConfig) createEmptyTriggerTree(triggerConfig)
@@ -67,10 +84,6 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
useEffect(() => {
setTriggerTree((prevTree) => QbUtils.checkTree(prevTree, triggerConfig))
}, [triggerConfig])
useEffect(() => { useEffect(() => {
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
if (currentPage > maxPage) { 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 fetchRules = async () => {
const api = (window as any).api const api = (window as any).api
if (!api || !canEdit) return if (!api || !canEdit) return
@@ -124,6 +150,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
useEffect(() => { useEffect(() => {
if (!canEdit) return if (!canEdit) return
fetchTags().catch(() => void 0)
fetchStudents().catch(() => void 0) fetchStudents().catch(() => void 0)
fetchRules().catch(() => void 0) fetchRules().catch(() => void 0)
}, [canEdit]) }, [canEdit])
@@ -387,10 +414,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
config={triggerConfig} config={triggerConfig}
value={triggerTree} value={triggerTree}
canEdit={canEdit} 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" }}> <div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
<Button type="primary" loading={saving} disabled={!canEdit} onClick={() => handleSubmit().catch(() => void 0)}> <Button type="primary" loading={saving} disabled={!canEdit} onClick={() => handleSubmit().catch(() => void 0)}>
@@ -1,17 +1,23 @@
import React, { useMemo } from "react" 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 { DeleteOutlined, PlusOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { ActionDraft, ActionEvent } from "./AutoScoreUtils" import type { ActionDraft, ActionEvent, AutoScoreTagOption } from "./AutoScoreUtils"
import { createDefaultActionDraft } from "./AutoScoreUtils" import { createDefaultActionDraft } from "./AutoScoreUtils"
interface ActionEditorProps { interface ActionEditorProps {
value: ActionDraft[] value: ActionDraft[]
tagOptions: AutoScoreTagOption[]
canEdit: boolean canEdit: boolean
onChange: (nextDrafts: ActionDraft[]) => void 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 { t } = useTranslation()
const actionOptions = useMemo( const actionOptions = useMemo(
@@ -22,6 +28,27 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({ value, canEdit, onCh
[t] [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">>) => { const updateAction = (id: string, patch: Partial<Omit<ActionDraft, "id">>) => {
onChange(value.map((item) => (item.id === id ? { ...item, ...patch } : item))) 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) => onChange={(event: ActionEvent) =>
updateAction(action.id, { updateAction(action.id, {
event, 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 <Select
style={{ minWidth: 220 }} mode="multiple"
showSearch
allowClear
style={{ minWidth: 260 }}
placeholder={t("autoScore.tagNamePlaceholder")} placeholder={t("autoScore.tagNamePlaceholder")}
value={action.value} value={Array.isArray(action.value) ? action.value : action.value ? [action.value] : []}
disabled={!canEdit} disabled={!canEdit}
onChange={(event) => updateAction(action.id, { value: event.target.value })} options={mergedTagOptions}
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
/> />
)} )}
<Button <Button
@@ -8,6 +8,7 @@ import {
type JsonRule, type JsonRule,
} from "@react-awesome-query-builder/antd" } from "@react-awesome-query-builder/antd"
import type { TFunction } from "i18next" import type { TFunction } from "i18next"
import { IntervalValueWidget, parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueWidget"
export interface AutoScoreTrigger { export interface AutoScoreTrigger {
event: string event: string
@@ -31,17 +32,25 @@ export interface AutoScoreRule {
export type ActionEvent = "add_score" | "add_tag" export type ActionEvent = "add_score" | "add_tag"
export interface AutoScoreTagOption {
label: string
value: string
}
export interface ActionDraft { export interface ActionDraft {
id: string id: string
event: ActionEvent event: ActionEvent
value: string value: string | string[]
} }
export type ActionDraftError = "score_required" | "tag_required" | null export type ActionDraftError = "score_required" | "tag_required" | null
const TRIGGER_FIELD_INTERVAL = "interval_minutes" const TRIGGER_FIELD_INTERVAL = "interval_minutes"
const TRIGGER_FIELD_TAG = "student_tag" const TRIGGER_FIELD_TAG = "student_tag"
const TRIGGER_TYPE_INTERVAL = "interval_duration"
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
const OP_EQUAL = "equal" const OP_EQUAL = "equal"
const OP_MULTISELECT_CONTAINS = "multiselect_contains"
const buildEmptyGroup = (): JsonGroup => ({ const buildEmptyGroup = (): JsonGroup => ({
id: QbUtils.uuid(), id: QbUtils.uuid(),
@@ -66,31 +75,71 @@ const toStringValue = (value: unknown): string => {
return String(value) 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 => { const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
if (trigger.event === "interval_time_passed") { if (trigger.event === "interval_time_passed") {
const minutes = toFiniteNumber(trigger.value) const intervalValue = parseIntervalTriggerValue(trigger.value)
if (!minutes || minutes <= 0) return null const serializedIntervalValue = intervalValue
? stringifyIntervalTriggerValue(intervalValue)
: null
if (!serializedIntervalValue) return null
return { return {
id: QbUtils.uuid(), id: QbUtils.uuid(),
type: "rule", type: "rule",
properties: { properties: {
field: TRIGGER_FIELD_INTERVAL, field: TRIGGER_FIELD_INTERVAL,
operator: OP_EQUAL, operator: OP_EQUAL,
value: [Math.floor(minutes)], value: [serializedIntervalValue],
}, },
} }
} }
if (trigger.event === "student_has_tag") { if (trigger.event === "student_has_tag") {
const tagName = toStringValue(trigger.value).trim() const tagNames = parseTagValues(trigger.value)
if (!tagName) return null if (tagNames.length === 0) return null
return { return {
id: QbUtils.uuid(), id: QbUtils.uuid(),
type: "rule", type: "rule",
properties: { properties: {
field: TRIGGER_FIELD_TAG, field: TRIGGER_FIELD_TAG,
operator: OP_EQUAL, operator: OP_MULTISELECT_CONTAINS,
value: [tagName], value: [tagNames],
}, },
} }
} }
@@ -103,20 +152,24 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined
if (field === TRIGGER_FIELD_INTERVAL) { if (field === TRIGGER_FIELD_INTERVAL) {
const minutes = toFiniteNumber(value) const intervalValue = parseIntervalTriggerValue(value)
if (!minutes || minutes <= 0) return null const serializedIntervalValue = intervalValue
? stringifyIntervalTriggerValue(intervalValue)
: null
if (!serializedIntervalValue) return null
return { return {
event: "interval_time_passed", event: "interval_time_passed",
value: String(Math.floor(minutes)), value: serializedIntervalValue,
} }
} }
if (field === TRIGGER_FIELD_TAG) { if (field === TRIGGER_FIELD_TAG) {
const tagName = toStringValue(value).trim() const tagNames = parseTagValues(value)
if (!tagName) return null const serializedValue = stringifyTagValues(tagNames)
if (!serializedValue) return null
return { return {
event: "student_has_tag", event: "student_has_tag",
value: tagName, value: serializedValue,
} }
} }
@@ -139,24 +192,61 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
return collected return collected
} }
export const createTriggerQueryConfig = (t: TFunction): Config => export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
({ ({
...AntdConfig, ...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: { fields: {
[TRIGGER_FIELD_INTERVAL]: { [TRIGGER_FIELD_INTERVAL]: {
label: t("autoScore.triggerIntervalTime"), label: t("autoScore.triggerIntervalTime"),
type: "number", type: TRIGGER_TYPE_INTERVAL,
operators: [OP_EQUAL], operators: [OP_EQUAL],
valueSources: ["value"], valueSources: ["value"],
preferWidgets: ["number"], preferWidgets: [TRIGGER_WIDGET_INTERVAL],
fieldSettings: { min: 1 },
}, },
[TRIGGER_FIELD_TAG]: { [TRIGGER_FIELD_TAG]: {
label: t("autoScore.triggerStudentTag"), label: t("autoScore.triggerStudentTag"),
type: "text", type: "multiselect",
operators: [OP_EQUAL], defaultOperator: OP_MULTISELECT_CONTAINS,
operators: [OP_MULTISELECT_CONTAINS],
valueSources: ["value"], valueSources: ["value"],
preferWidgets: ["text"], preferWidgets: ["multiselect"],
fieldSettings: {
listValues: tagOptions.map((tag) => ({
value: tag.value,
title: tag.label,
})),
allowCustomValues: true,
showSearch: true,
},
}, },
}, },
settings: { settings: {
@@ -229,7 +319,8 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
return { return {
id: QbUtils.uuid(), id: QbUtils.uuid(),
event: action.event, event: action.event,
value: toStringValue(action.value), value:
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value),
} satisfies ActionDraft } satisfies ActionDraft
}) })
.filter((item): item is ActionDraft => Boolean(item)) .filter((item): item is ActionDraft => Boolean(item))
@@ -243,7 +334,7 @@ export const actionDraftsToPayload = (
const actions: AutoScoreAction[] = [] const actions: AutoScoreAction[] = []
for (const draft of drafts) { for (const draft of drafts) {
if (draft.event === "add_score") { if (draft.event === "add_score") {
const score = toFiniteNumber(draft.value) const score = Array.isArray(draft.value) ? null : toFiniteNumber(draft.value)
if (!score || score === 0) { if (!score || score === 0) {
return { actions: [], error: "score_required" } return { actions: [], error: "score_required" }
} }
@@ -251,11 +342,11 @@ export const actionDraftsToPayload = (
continue continue
} }
const tagName = draft.value.trim() const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
if (!tagName) { if (!serializedTagValue) {
return { actions: [], error: "tag_required" } return { actions: [], error: "tag_required" }
} }
actions.push({ event: draft.event, value: tagName }) actions.push({ event: draft.event, value: serializedTagValue })
} }
return { actions, error: null } 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
+4
View File
@@ -803,6 +803,10 @@
"triggerStudentTag": "Student Tag", "triggerStudentTag": "Student Tag",
"actionAddScore": "Add Score", "actionAddScore": "Add Score",
"actionAddTag": "Add Tag", "actionAddTag": "Add Tag",
"intervalAmountPlaceholder": "Enter interval amount",
"intervalUnitMonth": "month(s) later",
"intervalUnitDay": "day(s) later",
"intervalUnitMinute": "minute(s) later",
"minutesPlaceholder": "Enter minutes", "minutesPlaceholder": "Enter minutes",
"minutes": "minutes", "minutes": "minutes",
"tagsPlaceholder": "Enter tag name", "tagsPlaceholder": "Enter tag name",
+4
View File
@@ -801,6 +801,10 @@
"triggerStudentTag": "学生标签", "triggerStudentTag": "学生标签",
"actionAddScore": "加分", "actionAddScore": "加分",
"actionAddTag": "添加标签", "actionAddTag": "添加标签",
"intervalAmountPlaceholder": "请输入间隔数量",
"intervalUnitMonth": "个月后",
"intervalUnitDay": "天后",
"intervalUnitMinute": "分钟后",
"minutesPlaceholder": "请输入分钟数", "minutesPlaceholder": "请输入分钟数",
"minutes": "分钟", "minutes": "分钟",
"tagsPlaceholder": "请输入标签名称", "tagsPlaceholder": "请输入标签名称",