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 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!(
+13 -12
View File
@@ -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,17 +1228,15 @@ pub async fn db_switch_connection(
connection_string
};
let conn = create_sqlite_connection(&path)
.await
.map_err(|e| {
logger.log(
LogLevel::Error,
&format!("SQLite connection failed: {}", e),
Some("database"),
None,
);
e.to_string()
})?;
let conn = create_sqlite_connection(&path).await.map_err(|e| {
logger.log(
LogLevel::Error,
&format!("SQLite connection failed: {}", e),
Some("database"),
None,
);
e.to_string()
})?;
run_migration(&conn, DatabaseType::SQLite)
.await
.map_err(|e| {
+15 -4
View File
@@ -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}&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!({
"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())
+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);
}
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>>(())
});
+64 -16
View File
@@ -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,23 +185,17 @@ 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 delay_ms = (next_execute_time - now).num_milliseconds();
return Some(delay_ms.max(0));
}
}
return Some(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));
}
}
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))
}
}
}
+1 -3
View File
@@ -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
+3
View File
@@ -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 {
+41 -9
View File
@@ -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
+4
View File
@@ -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",
+4
View File
@@ -801,6 +801,10 @@
"triggerStudentTag": "学生标签",
"actionAddScore": "加分",
"actionAddTag": "添加标签",
"intervalAmountPlaceholder": "请输入间隔数量",
"intervalUnitMonth": "个月后",
"intervalUnitDay": "天后",
"intervalUnitMinute": "分钟后",
"minutesPlaceholder": "请输入分钟数",
"minutes": "分钟",
"tagsPlaceholder": "请输入标签名称",