feat: Enhance AutoScore functionality with interval triggers and JSON tree support

- Added support for trigger trees in AutoScoreRule interface.
- Implemented functions to convert query trees to JSON and vice versa.
- Updated IntervalValue to use days, hours, and minutes instead of a single amount and unit.
- Refactored IntervalValueWidget to allow separate input for days, hours, and minutes.
- Added validation to ensure at least one interval trigger is present.
- Enhanced user interface with informative alerts and tooltips for better user experience.
- Updated localization files to include new strings for interval triggers and UI elements.
This commit is contained in:
NanGua-QWQ
2026-04-10 21:09:04 +08:00
parent 3caea0afe5
commit eef6b703f7
13 changed files with 931 additions and 294 deletions
@@ -1,116 +1,87 @@
import React, { useMemo } from "react"
import { InputNumber, Select } from "antd"
import React from "react"
import { InputNumber } from "antd"
import type { WidgetProps } from "@react-awesome-query-builder/antd"
import { useTranslation } from "react-i18next"
import {
getDefaultIntervalValue,
parseIntervalTriggerValue,
stringifyIntervalTriggerValue,
type IntervalValue,
} from "./IntervalValueCodec"
export type IntervalUnit = "month" | "day" | "minute"
export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec"
export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec"
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
const buildNextIntervalValue = (
currentValue: IntervalValue | null,
patch: Partial<IntervalValue>
): IntervalValue => {
const base = currentValue ?? getDefaultIntervalValue()
return {
days: patch.days ?? base.days,
hours: patch.hours ?? base.hours,
minutes: patch.minutes ?? base.minutes,
}
return null
}
export const stringifyIntervalTriggerValue = (value: IntervalValue): string => {
return JSON.stringify({
amount: value.amount,
unit: value.unit,
})
}
const getDisplayValue = (value: number | undefined, fallback = 0) =>
typeof value === "number" && Number.isFinite(value) ? value : fallback
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)
const updateValue = (patch: Partial<IntervalValue>) => {
const nextValue = buildNextIntervalValue(parsedValue, patch)
const serialized = stringifyIntervalTriggerValue(nextValue)
setValue(serialized)
}
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 style={{ display: "flex", gap: 12, minWidth: 360 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
{t("autoScore.intervalPartDay")}
</span>
<InputNumber
min={0}
precision={0}
disabled={readonly}
value={getDisplayValue(parsedValue?.days)}
onChange={(next) => updateValue({ days: Math.max(0, Number(next ?? 0)) })}
style={{ width: "100%" }}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
{t("autoScore.intervalPartHour")}
</span>
<InputNumber
min={0}
precision={0}
disabled={readonly}
value={getDisplayValue(parsedValue?.hours)}
onChange={(next) => updateValue({ hours: Math.max(0, Number(next ?? 0)) })}
style={{ width: "100%" }}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
{t("autoScore.intervalPartMinute")}
</span>
<InputNumber
min={0}
precision={0}
disabled={readonly}
value={getDisplayValue(parsedValue?.minutes)}
onChange={(next) => updateValue({ minutes: Math.max(0, Number(next ?? 0)) })}
style={{ width: "100%" }}
/>
</div>
</div>
)
}