diff --git a/src/App.tsx b/src/App.tsx index e90f30d..6a604f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,7 +73,6 @@ function MainContent(): React.JSX.Element { if (p.startsWith("/settlements")) return "settlements" if (p.startsWith("/reasons")) return "reasons" if (p.startsWith("/auto-score")) return "auto-score" - if (p.startsWith("/reward-exchange")) return "reward-exchange" if (p.startsWith("/reward-settings")) return "reward-settings" if (p.startsWith("/settings")) return "settings" return "home" @@ -274,7 +273,6 @@ function MainContent(): React.JSX.Element { if (key === "settlements") navigate("/settlements") if (key === "reasons") navigate("/reasons") if (key === "auto-score") navigate("/auto-score") - if (key === "reward-exchange") navigate("/reward-exchange") if (key === "reward-settings") navigate("/reward-settings") if (key === "settings") navigate("/settings") } diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index 2dd8b36..e5ed407 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -13,7 +13,6 @@ const loadScoreManager = () => import("./ScoreManager") const loadLeaderboard = () => import("./Leaderboard") const loadSettlementHistory = () => import("./SettlementHistory") const loadAutoScoreManager = () => import("./AutoScoreManager") -const loadRewardExchange = () => import("./RewardExchange") const loadRewardSettings = () => import("./RewardSettings") const Home = lazy(() => loadHome().then((m) => ({ default: m.Home }))) @@ -28,9 +27,6 @@ const SettlementHistory = lazy(() => const AutoScoreManager = lazy(() => loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager })) ) -const RewardExchange = lazy(() => - loadRewardExchange().then((m) => ({ default: m.RewardExchange })) -) const RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings })) ) @@ -45,7 +41,6 @@ const warmupRouteChunks = () => loadLeaderboard(), loadSettlementHistory(), loadAutoScoreManager(), - loadRewardExchange(), loadRewardSettings(), ]) @@ -245,10 +240,6 @@ export function ContentArea({ } /> } /> } /> - } - /> } diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 1e184bc..fa0c4f3 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -9,6 +9,7 @@ interface student { id: number name: string score: number + reward_points: number extra_json?: string | null avatarUrl?: string | null pinyinName?: string @@ -34,6 +35,12 @@ interface scoreEvent { event_time: string } +interface rewardSetting { + id: number + name: string + cost_points: number +} + type SortType = "alphabet" | "surname" | "score" type LayoutType = "grouped" | "squareGrid" type SearchKeyboardLayout = "t9" | "qwerty26" @@ -76,6 +83,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const { t } = useTranslation() const [students, setStudents] = useState([]) const [reasons, setReasons] = useState([]) + const [rewards, setRewards] = useState([]) const [loading, setLoading] = useState(false) const [sortType, setSortType] = useState("alphabet") const [layoutType, setLayoutType] = useState("grouped") @@ -99,6 +107,10 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const [latestEvent, setLatestEvent] = useState(null) const [messageApi, contextHolder] = message.useMessage() const [quickActionStudentId, setQuickActionStudentId] = useState(null) + const [rewardMode, setRewardMode] = useState(false) + const [rewardStudent, setRewardStudent] = useState(null) + const [rewardModalVisible, setRewardModalVisible] = useState(false) + const [redeemLoading, setRedeemLoading] = useState(false) const longPressTimerRef = useRef(null) const suppressClickRef = useRef(false) const fetchRequestIdRef = useRef(0) @@ -137,9 +149,10 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const requestId = ++fetchRequestIdRef.current logHome("fetchData:start", { requestId, silent }) if (!silent) setLoading(true) - const [stuRes, reaRes] = await Promise.all([ + const [stuRes, reaRes, rewRes] = await Promise.all([ (window as any).api.queryStudents({}), (window as any).api.queryReasons(), + (window as any).api.rewardSettingQuery(), ]) if (requestId !== fetchRequestIdRef.current) return @@ -163,6 +176,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = setStudents(enrichedStudents) } if (reaRes.success) setReasons(reaRes.data) + if (rewRes.success) setRewards(rewRes.data) if (!silent) setLoading(false) }, []) @@ -373,6 +387,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = return false }, []) + const getDisplayPoints = useCallback( + (s: student) => (rewardMode ? Number(s.reward_points || 0) : Number(s.score || 0)), + [rewardMode] + ) + const sortedStudents = useMemo(() => { const filtered = students.filter((s) => matchStudentName(s, searchKeyword)) @@ -393,11 +412,13 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = return surnameA.localeCompare(surnameB, "zh-CN") }) case "score": - return filtered.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "zh-CN")) + return filtered.sort( + (a, b) => getDisplayPoints(b) - getDisplayPoints(a) || a.name.localeCompare(b.name, "zh-CN") + ) default: return filtered } - }, [students, searchKeyword, sortType, matchStudentName]) + }, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints]) const groupedStudents = useMemo(() => { if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) { @@ -479,6 +500,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = messageApi.error(t("common.readOnly")) return } + if (rewardMode) { + setRewardStudent(student) + setRewardModalVisible(true) + return + } if (batchMode) { setSelectedStudentIds((prev) => prev.includes(student.id) ? prev.filter((id) => id !== student.id) : [...prev, student.id] @@ -491,6 +517,62 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = setOperationVisible(true) } + const handleToggleRewardMode = () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + if (!rewardMode && rewards.length === 0) { + messageApi.warning(t("rewardExchange.noAffordableRewards")) + return + } + setRewardMode((prev) => !prev) + setBatchMode(false) + setSelectedStudentIds([]) + setOperationVisible(false) + setQuickActionStudentId(null) + setRewardStudent(null) + setRewardModalVisible(false) + } + + const affordableRewards = useMemo(() => { + if (!rewardStudent) return [] + return rewards + .filter((r) => r.cost_points <= Number(rewardStudent.reward_points || 0)) + .sort((a, b) => a.cost_points - b.cost_points || a.name.localeCompare(b.name, "zh-CN")) + }, [rewards, rewardStudent]) + + const handleRedeemReward = async (reward: rewardSetting) => { + if (!(window as any).api || !rewardStudent) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setRedeemLoading(true) + const res = await (window as any).api.rewardRedeem({ + student_name: rewardStudent.name, + reward_id: reward.id, + }) + setRedeemLoading(false) + if (res.success) { + messageApi.success( + t("rewardExchange.redeemSuccess", { + student: rewardStudent.name, + reward: reward.name, + points: reward.cost_points, + }) + ) + setRewardModalVisible(false) + setRewardStudent(null) + setRewardMode(false) + fetchData(true) + fetchLatestEvent() + emitDataUpdated("students") + return + } + messageApi.error(res.message || t("rewardExchange.redeemFailed")) + } + const performSubmit = async (targetStudents: student[], delta: number, content: string) => { if (!(window as any).api) return if (!canEdit) { @@ -621,6 +703,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } const startLongPress = (student: student) => { + if (rewardMode) return cancelLongPress() longPressTimerRef.current = window.setTimeout(() => { if (!canEdit) { @@ -634,6 +717,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } const openQuickAction = (student: student) => { + if (rewardMode) return cancelLongPress() if (!canEdit) { messageApi.error(t("common.readOnly")) @@ -644,6 +728,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } const handleQuickAdjust = (student: student, delta: number) => { + if (rewardMode) return const content = delta > 0 ? t("home.addPoints") : t("home.deductPoints") performSubmit([student], delta, content) setQuickActionStudentId(null) @@ -890,10 +975,16 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = )} 0 ? "success" : student.score < 0 ? "error" : "default"} + color={ + getDisplayPoints(student) > 0 + ? "success" + : getDisplayPoints(student) < 0 + ? "error" + : "default" + } style={{ fontWeight: "bold" }} > - {student.score > 0 ? `+${student.score}` : student.score} + {getDisplayPoints(student) > 0 ? `+${getDisplayPoints(student)}` : getDisplayPoints(student)} @@ -1035,10 +1126,12 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = )} 0 ? "success" : student.score < 0 ? "error" : "default"} + color={ + getDisplayPoints(student) > 0 ? "success" : getDisplayPoints(student) < 0 ? "error" : "default" + } style={{ fontWeight: "bold", marginInlineEnd: 0 }} > - {student.score > 0 ? `+${student.score}` : student.score} + {getDisplayPoints(student) > 0 ? `+${getDisplayPoints(student)}` : getDisplayPoints(student)} )} @@ -1231,10 +1324,16 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = {student.name} 0 ? "success" : student.score < 0 ? "error" : "default"} + color={ + getDisplayPoints(student) > 0 + ? "success" + : getDisplayPoints(student) < 0 + ? "error" + : "default" + } style={{ fontWeight: "bold", marginInlineEnd: 0 }} > - {student.score > 0 ? `+${student.score}` : student.score} + {getDisplayPoints(student) > 0 ? `+${getDisplayPoints(student)}` : getDisplayPoints(student)} {isSelected && ( @@ -1952,6 +2051,36 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = }) }, []) + const batchToolbar = rewardMode + ? null + : !batchMode + ? ( + + ) + : ( + + + + + + + ) + return (
= ({ canEdit, isPortraitMode = false }) = icon={} onClick={handleUndoLastEvent} loading={undoLoading} - disabled={!canEdit || !latestEvent} + disabled={!canEdit || !latestEvent || rewardMode} title={ latestEvent ? t("home.undoLastHint", { @@ -2118,31 +2247,10 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = > {t("home.undoLastAction")} - {!batchMode ? ( - - ) : ( - - - - - - - )} + + {batchToolbar}
@@ -2183,6 +2291,54 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = )} + { + setRewardModalVisible(false) + setRewardStudent(null) + }} + footer={null} + destroyOnHidden + > + {!rewardStudent ? null : ( + <> +
+ {t("rewardExchange.currentRewardPoints", { points: rewardStudent.reward_points })} +
+ {affordableRewards.length === 0 ? ( +
{t("rewardExchange.noAffordableRewards")}
+ ) : ( +
+ {affordableRewards.map((reward) => ( +
+
+
{reward.name}
+
+ {t("rewardExchange.costLabel", { points: reward.cost_points })} +
+
+ +
+ ))} +
+ )} + + )} +
+ {isPortraitMode ? ( , label: t("sidebar.autoScore"), }, - { - key: "reward-exchange", - icon: , - label: t("sidebar.rewardExchange"), - }, { key: "reward-settings", icon: ,