mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 11:49:02 +08:00
feat: 新增学生小组设置与主页小组排序
This commit is contained in:
+33
-5
@@ -21,6 +21,7 @@ import { useResponsive } from "../hooks/useResponsive"
|
||||
interface student {
|
||||
id: number
|
||||
name: string
|
||||
group_name?: string | null
|
||||
score: number
|
||||
reward_points: number
|
||||
extra_json?: string | null
|
||||
@@ -54,7 +55,7 @@ interface rewardSetting {
|
||||
cost_points: number
|
||||
}
|
||||
|
||||
type SortType = "alphabet" | "surname" | "score"
|
||||
type SortType = "alphabet" | "surname" | "group" | "score"
|
||||
type LayoutType = "grouped" | "squareGrid"
|
||||
type SearchKeyboardLayout = "t9" | "qwerty26"
|
||||
|
||||
@@ -160,6 +161,11 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
return py ? py.toUpperCase() : "#"
|
||||
}
|
||||
|
||||
const getGroupName = useCallback(
|
||||
(groupName?: string | null) => groupName?.trim() || t("home.ungrouped"),
|
||||
[t]
|
||||
)
|
||||
|
||||
const fetchData = useCallback(async (silent = false) => {
|
||||
if (!(window as any).api) return
|
||||
const requestId = ++fetchRequestIdRef.current
|
||||
@@ -438,10 +444,21 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
(a, b) =>
|
||||
getDisplayPoints(b) - getDisplayPoints(a) || a.name.localeCompare(b.name, "zh-CN")
|
||||
)
|
||||
case "group":
|
||||
return filtered.sort((a, b) => {
|
||||
const groupA = getGroupName(a.group_name)
|
||||
const groupB = getGroupName(b.group_name)
|
||||
if (groupA === groupB) {
|
||||
return (a.pinyinName || "").localeCompare(b.pinyinName || "")
|
||||
}
|
||||
if (groupA === t("home.ungrouped")) return 1
|
||||
if (groupB === t("home.ungrouped")) return -1
|
||||
return groupA.localeCompare(groupB, "zh-CN")
|
||||
})
|
||||
default:
|
||||
return filtered
|
||||
}
|
||||
}, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints])
|
||||
}, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints, getGroupName, t])
|
||||
|
||||
const groupedStudents = useMemo(() => {
|
||||
if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) {
|
||||
@@ -450,15 +467,25 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
|
||||
const groups: Record<string, student[]> = {}
|
||||
sortedStudents.forEach((s) => {
|
||||
const key = sortType === "alphabet" ? s.pinyinFirst || "#" : getSurname(s.name)
|
||||
const key =
|
||||
sortType === "alphabet"
|
||||
? s.pinyinFirst || "#"
|
||||
: sortType === "surname"
|
||||
? getSurname(s.name)
|
||||
: getGroupName(s.group_name)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(s)
|
||||
})
|
||||
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b, "zh-CN"))
|
||||
.sort(([a], [b]) => {
|
||||
const ungrouped = t("home.ungrouped")
|
||||
if (a === ungrouped) return 1
|
||||
if (b === ungrouped) return -1
|
||||
return a.localeCompare(b, "zh-CN")
|
||||
})
|
||||
.map(([key, students]) => ({ key, students }))
|
||||
}, [sortedStudents, sortType, searchKeyword, layoutType])
|
||||
}, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t])
|
||||
|
||||
const firstStudentIdByGroup = useMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
@@ -2281,6 +2308,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
options={[
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "group", label: t("home.sortBy.group") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const createXlsxWorker = () => {
|
||||
interface student {
|
||||
id: number
|
||||
name: string
|
||||
group_name?: string | null
|
||||
score: number
|
||||
tags?: string[]
|
||||
tagIds?: number[]
|
||||
@@ -34,6 +35,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [xlsxVisible, setXlsxVisible] = useState(false)
|
||||
const [tagEditVisible, setTagEditVisible] = useState(false)
|
||||
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
||||
const [groupEditVisible, setGroupEditVisible] = useState(false)
|
||||
const [groupEditStudent, setGroupEditStudent] = useState<student | null>(null)
|
||||
const [groupSaving, setGroupSaving] = useState(false)
|
||||
const [avatarVisible, setAvatarVisible] = useState(false)
|
||||
const [avatarSaving, setAvatarSaving] = useState(false)
|
||||
const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
|
||||
@@ -48,6 +52,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const xlsxWorkerRef = useRef<Worker | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const [groupForm] = Form.useForm()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
const isTablet = useIsTablet()
|
||||
@@ -89,6 +94,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
group_name: s.group_name ?? null,
|
||||
score: s.score,
|
||||
extra_json: s.extra_json ?? null,
|
||||
avatarUrl: getAvatarFromExtraJson(s.extra_json),
|
||||
@@ -135,12 +141,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
const name = values.name.trim()
|
||||
const groupName =
|
||||
typeof values.group_name === "string" && values.group_name.trim()
|
||||
? values.group_name.trim()
|
||||
: undefined
|
||||
if (data.some((s) => s.name === name)) {
|
||||
messageApi.warning(t("students.nameExists"))
|
||||
return
|
||||
}
|
||||
|
||||
const res = await (window as any).api.createStudent({ ...values, name })
|
||||
const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName })
|
||||
if (res.success) {
|
||||
messageApi.success(t("students.addSuccess"))
|
||||
setVisible(false)
|
||||
@@ -202,6 +212,46 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setAvatarVisible(true)
|
||||
}
|
||||
|
||||
const handleOpenGroupEditor = (student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setGroupEditStudent(student)
|
||||
groupForm.setFieldsValue({ group_name: student.group_name || "" })
|
||||
setGroupEditVisible(true)
|
||||
}
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
if (!(window as any).api || !groupEditStudent) return
|
||||
|
||||
try {
|
||||
const values = await groupForm.validateFields()
|
||||
const groupName =
|
||||
typeof values.group_name === "string" && values.group_name.trim()
|
||||
? values.group_name.trim()
|
||||
: ""
|
||||
setGroupSaving(true)
|
||||
const res = await (window as any).api.updateStudent(groupEditStudent.id, {
|
||||
group_name: groupName,
|
||||
})
|
||||
if (res?.success) {
|
||||
messageApi.success(t("students.groupSaveSuccess"))
|
||||
setGroupEditVisible(false)
|
||||
setGroupEditStudent(null)
|
||||
groupForm.resetFields()
|
||||
fetchStudents()
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
messageApi.error(res?.message || t("students.groupSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
setGroupSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -521,6 +571,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
width: isMobile ? 80 : 100,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t("students.group"),
|
||||
dataIndex: "group_name",
|
||||
key: "group_name",
|
||||
width: isMobile ? 90 : 120,
|
||||
ellipsis: true,
|
||||
render: (groupName?: string | null) => groupName?.trim() || t("students.noGroup"),
|
||||
},
|
||||
{
|
||||
title: t("students.currentScore"),
|
||||
dataIndex: "score",
|
||||
@@ -586,6 +644,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
>
|
||||
{t("students.editTags")}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size={isMobile ? "small" : "middle"}
|
||||
disabled={!canEdit}
|
||||
onClick={() => handleOpenGroupEditor(row)}
|
||||
style={{ padding: isMobile ? "2px 4px" : undefined }}
|
||||
>
|
||||
{t("students.editGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size={isMobile ? "small" : "middle"}
|
||||
@@ -673,6 +740,30 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
>
|
||||
<Input placeholder={t("students.namePlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("students.group")} name="group_name">
|
||||
<Input placeholder={t("students.groupPlaceholder")} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t("students.editGroupTitle", { name: groupEditStudent?.name || "" })}
|
||||
open={groupEditVisible}
|
||||
onCancel={() => {
|
||||
setGroupEditVisible(false)
|
||||
setGroupEditStudent(null)
|
||||
groupForm.resetFields()
|
||||
}}
|
||||
onOk={handleSaveGroup}
|
||||
okText={t("common.save")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ loading: groupSaving, disabled: !groupEditStudent }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={groupForm} layout="vertical">
|
||||
<Form.Item label={t("students.group")} name="group_name">
|
||||
<Input placeholder={t("students.groupPlaceholder")} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -390,6 +390,7 @@
|
||||
"sortBy": {
|
||||
"alphabet": "Name Sort",
|
||||
"surname": "Surname Group",
|
||||
"group": "Group Sort",
|
||||
"score": "Points Rank"
|
||||
},
|
||||
"layoutBy": {
|
||||
@@ -409,6 +410,7 @@
|
||||
"reasonPlaceholder": "Enter reason for points change (optional)",
|
||||
"preview": "Change Preview",
|
||||
"noReason": "(No reason)",
|
||||
"ungrouped": "Ungrouped",
|
||||
"category": {
|
||||
"others": "Others"
|
||||
},
|
||||
@@ -445,6 +447,7 @@
|
||||
"importList": "Import List",
|
||||
"addStudent": "Add Student",
|
||||
"name": "Name",
|
||||
"group": "Group",
|
||||
"avatar": "Avatar",
|
||||
"currentScore": "Current Points",
|
||||
"tags": "Tags",
|
||||
@@ -455,6 +458,7 @@
|
||||
"addTitle": "Add Student",
|
||||
"addConfirm": "Add",
|
||||
"namePlaceholder": "Enter student name",
|
||||
"groupPlaceholder": "Enter group name (optional)",
|
||||
"nameRequired": "Please enter name",
|
||||
"nameExists": "Student name already exists",
|
||||
"addSuccess": "Added successfully",
|
||||
@@ -480,6 +484,11 @@
|
||||
"noNamesFound": "No importable names found in selected column",
|
||||
"importFailed": "Import failed",
|
||||
"editTagTitle": "Edit Tags - {{name}}",
|
||||
"editGroup": "Set Group",
|
||||
"editGroupTitle": "Set Group - {{name}}",
|
||||
"groupSaveSuccess": "Group saved",
|
||||
"groupSaveFailed": "Failed to save group",
|
||||
"noGroup": "Ungrouped",
|
||||
"editAvatarTitle": "Set Avatar - {{name}}",
|
||||
"avatarUpload": "Upload Image",
|
||||
"avatarClear": "Clear Avatar",
|
||||
|
||||
@@ -390,6 +390,7 @@
|
||||
"sortBy": {
|
||||
"alphabet": "姓名排序",
|
||||
"surname": "姓氏分组",
|
||||
"group": "小组排序",
|
||||
"score": "积分排行"
|
||||
},
|
||||
"layoutBy": {
|
||||
@@ -409,6 +410,7 @@
|
||||
"reasonPlaceholder": "输入加分/扣分的原因(可选)",
|
||||
"preview": "变更预览",
|
||||
"noReason": "(无理由)",
|
||||
"ungrouped": "未分组",
|
||||
"category": {
|
||||
"others": "其他"
|
||||
},
|
||||
@@ -445,6 +447,7 @@
|
||||
"importList": "导入名单",
|
||||
"addStudent": "添加学生",
|
||||
"name": "姓名",
|
||||
"group": "小组",
|
||||
"avatar": "头像",
|
||||
"currentScore": "当前积分",
|
||||
"tags": "标签",
|
||||
@@ -455,6 +458,7 @@
|
||||
"addTitle": "添加学生",
|
||||
"addConfirm": "添加",
|
||||
"namePlaceholder": "请输入学生姓名",
|
||||
"groupPlaceholder": "请输入小组名称(可选)",
|
||||
"nameRequired": "请输入姓名",
|
||||
"nameExists": "学生姓名已存在",
|
||||
"addSuccess": "添加成功",
|
||||
@@ -480,6 +484,11 @@
|
||||
"noNamesFound": "所选列未解析到可导入的姓名",
|
||||
"importFailed": "导入失败",
|
||||
"editTagTitle": "编辑标签 - {{name}}",
|
||||
"editGroup": "设置小组",
|
||||
"editGroupTitle": "设置小组 - {{name}}",
|
||||
"groupSaveSuccess": "小组保存成功",
|
||||
"groupSaveFailed": "小组保存失败",
|
||||
"noGroup": "未分组",
|
||||
"editAvatarTitle": "设置头像 - {{name}}",
|
||||
"avatarUpload": "上传图片",
|
||||
"avatarClear": "清除头像",
|
||||
|
||||
@@ -79,6 +79,7 @@ const api = {
|
||||
invoke("student_query", { params }),
|
||||
createStudent: (data: {
|
||||
name: string
|
||||
group_name?: string
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("student_create", { data }),
|
||||
updateStudent: (id: number, data: any): Promise<{ success: boolean }> =>
|
||||
|
||||
Reference in New Issue
Block a user