mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
将奖励兑换模式入口整合到积分主页并移除独立导航
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/auto-score" element={<AutoScoreManager />} />
|
||||
<Route
|
||||
path="/reward-exchange"
|
||||
element={<RewardExchange canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
|
||||
+191
-35
@@ -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<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
const { t } = useTranslation()
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [rewards, setRewards] = useState<rewardSetting[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sortType, setSortType] = useState<SortType>("alphabet")
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("grouped")
|
||||
@@ -99,6 +107,10 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
const [latestEvent, setLatestEvent] = useState<scoreEvent | null>(null)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [quickActionStudentId, setQuickActionStudentId] = useState<number | null>(null)
|
||||
const [rewardMode, setRewardMode] = useState(false)
|
||||
const [rewardStudent, setRewardStudent] = useState<student | null>(null)
|
||||
const [rewardModalVisible, setRewardModalVisible] = useState(false)
|
||||
const [redeemLoading, setRedeemLoading] = useState(false)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
const fetchRequestIdRef = useRef(0)
|
||||
@@ -137,9 +149,10 @@ export const Home: React.FC<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ 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<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
</Tag>
|
||||
)}
|
||||
<Tag
|
||||
color={student.score > 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)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1035,10 +1126,12 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
</Tag>
|
||||
)}
|
||||
<Tag
|
||||
color={student.score > 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)}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
@@ -1231,10 +1324,16 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
{student.name}
|
||||
</div>
|
||||
<Tag
|
||||
color={student.score > 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)}
|
||||
</Tag>
|
||||
{isSelected && (
|
||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||
@@ -1952,6 +2051,36 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
})
|
||||
}, [])
|
||||
|
||||
const batchToolbar = rewardMode
|
||||
? null
|
||||
: !batchMode
|
||||
? (
|
||||
<Button onClick={handleEnterBatchMode} disabled={!canEdit}>
|
||||
{t("home.multiSelect")}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Space size={8} wrap>
|
||||
<Button onClick={handleSelectAllStudents} disabled={!canEdit || students.length === 0}>
|
||||
{t("home.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearSelectedStudents}
|
||||
disabled={!canEdit || selectedStudentIds.length === 0}
|
||||
>
|
||||
{t("home.clearSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleOpenBatchOperation}
|
||||
disabled={!canEdit || selectedStudentIds.length === 0}
|
||||
>
|
||||
{t("home.batchOperate")}
|
||||
</Button>
|
||||
<Button onClick={handleExitBatchMode}>{t("common.cancel")}</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -2106,7 +2235,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
icon={<UndoOutlined />}
|
||||
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<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
>
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
{!batchMode ? (
|
||||
<Button onClick={handleEnterBatchMode} disabled={!canEdit}>
|
||||
{t("home.multiSelect")}
|
||||
</Button>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button onClick={handleSelectAllStudents} disabled={!canEdit || students.length === 0}>
|
||||
{t("home.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearSelectedStudents}
|
||||
disabled={!canEdit || selectedStudentIds.length === 0}
|
||||
>
|
||||
{t("home.clearSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleOpenBatchOperation}
|
||||
disabled={!canEdit || selectedStudentIds.length === 0}
|
||||
>
|
||||
{t("home.batchOperate")}
|
||||
</Button>
|
||||
<Button onClick={handleExitBatchMode}>{t("common.cancel")}</Button>
|
||||
</Space>
|
||||
)}
|
||||
<Button type={rewardMode ? "default" : "primary"} onClick={handleToggleRewardMode} disabled={!canEdit}>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
{batchToolbar}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -2183,6 +2291,54 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t("rewardExchange.chooseRewardTitle", { name: rewardStudent?.name || "" })}
|
||||
open={rewardModalVisible}
|
||||
onCancel={() => {
|
||||
setRewardModalVisible(false)
|
||||
setRewardStudent(null)
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
{!rewardStudent ? null : (
|
||||
<>
|
||||
<div style={{ marginBottom: "12px", color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
||||
{t("rewardExchange.currentRewardPoints", { points: rewardStudent.reward_points })}
|
||||
</div>
|
||||
{affordableRewards.length === 0 ? (
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>{t("rewardExchange.noAffordableRewards")}</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{affordableRewards.map((reward) => (
|
||||
<div
|
||||
key={reward.id}
|
||||
style={{
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{reward.name}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("rewardExchange.costLabel", { points: reward.cost_points })}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" loading={redeemLoading} onClick={() => handleRedeemReward(reward)}>
|
||||
{t("rewardExchange.redeemNow")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{isPortraitMode ? (
|
||||
<Drawer
|
||||
title={
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
FileTextOutlined,
|
||||
CloudOutlined,
|
||||
UploadOutlined,
|
||||
GiftOutlined,
|
||||
AppstoreAddOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useState, useEffect } from "react"
|
||||
@@ -168,11 +167,6 @@ export function Sidebar({
|
||||
icon: <SyncOutlined />,
|
||||
label: t("sidebar.autoScore"),
|
||||
},
|
||||
{
|
||||
key: "reward-exchange",
|
||||
icon: <GiftOutlined />,
|
||||
label: t("sidebar.rewardExchange"),
|
||||
},
|
||||
{
|
||||
key: "reward-settings",
|
||||
icon: <AppstoreAddOutlined />,
|
||||
|
||||
Reference in New Issue
Block a user