mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
feat:学生Tag
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
|
||||
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Tag } from 'tdesign-react'
|
||||
import type { PrimaryTableCol } from 'tdesign-react'
|
||||
import { TagEditorDialog } from './TagEditorDialog'
|
||||
|
||||
// 创建 XLSX Worker
|
||||
const createXlsxWorker = () => {
|
||||
@@ -13,6 +14,8 @@ interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
tags?: string[]
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
@@ -23,6 +26,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [importVisible, setImportVisible] = useState(false)
|
||||
const [xlsxVisible, setXlsxVisible] = useState(false)
|
||||
const [tagEditVisible, setTagEditVisible] = useState(false)
|
||||
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
||||
const [xlsxLoading, setXlsxLoading] = useState(false)
|
||||
const [xlsxFileName, setXlsxFileName] = useState('')
|
||||
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
||||
@@ -49,7 +54,37 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
try {
|
||||
const res = await (window as any).api.queryStudents({})
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
try {
|
||||
const students = await Promise.all(
|
||||
(res.data as any[]).map(async (s) => {
|
||||
let tagIds: number[] = []
|
||||
let tags: string[] = []
|
||||
|
||||
try {
|
||||
const tagsRes = await (window as any).api.tagsGetByStudent(s.id)
|
||||
if (tagsRes.success && tagsRes.data) {
|
||||
tagIds = tagsRes.data.map((t: any) => t.id)
|
||||
tags = tagsRes.data.map((t: any) => t.name)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch tags for student:', s.id, e)
|
||||
}
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
score: s.score,
|
||||
tags,
|
||||
tagIds
|
||||
}
|
||||
})
|
||||
)
|
||||
console.debug('Fetched students:', students)
|
||||
setData(students)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse students response, falling back:', e)
|
||||
setData(res.data)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch students:', e)
|
||||
@@ -133,6 +168,36 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenTagEditor = (student: student) => {
|
||||
if (!canEdit) {
|
||||
MessagePlugin.error('当前为只读权限')
|
||||
return
|
||||
}
|
||||
setEditingStudent(student)
|
||||
setTagEditVisible(true)
|
||||
}
|
||||
|
||||
const handleSaveTags = async (tagIds: number[]) => {
|
||||
if (!editingStudent || !(window as any).api) return
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds)
|
||||
if (res && res.success) {
|
||||
MessagePlugin.success('标签保存成功')
|
||||
setTagEditVisible(false)
|
||||
setEditingStudent(null)
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
} else {
|
||||
const errorMsg = res?.message || '保存标签失败'
|
||||
MessagePlugin.error(errorMsg)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
MessagePlugin.error(`保存标签失败: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const excelColName = (idx: number) => {
|
||||
let n = idx + 1
|
||||
let s = ''
|
||||
@@ -284,11 +349,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<student>[] = [
|
||||
{ colKey: 'name', title: '姓名', width: 200 },
|
||||
{ colKey: 'name', title: '姓名', width: 100 },
|
||||
{
|
||||
colKey: 'score',
|
||||
title: '当前积分',
|
||||
width: 120,
|
||||
width: 160,
|
||||
align: 'center',
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
@@ -306,12 +371,46 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
colKey: 'tags',
|
||||
title: '标签',
|
||||
width: 200,
|
||||
cell: ({ row }) => {
|
||||
const tags = row.tags || []
|
||||
return (
|
||||
<Space>
|
||||
{tags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>无标签</span>
|
||||
) : (
|
||||
tags.slice(0, 3).map(tag => (
|
||||
<Tag key={tag} theme="primary" size="small">
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
)}
|
||||
{tags.length > 3 && (
|
||||
<Tag theme="default" size="small">
|
||||
+{tags.length - 3}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'operation',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
width: 150,
|
||||
cell: ({ row }) => (
|
||||
<Space>
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
disabled={!canEdit}
|
||||
onClick={() => handleOpenTagEditor(row)}
|
||||
>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button
|
||||
theme="danger"
|
||||
variant="text"
|
||||
@@ -427,6 +526,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* 标签编辑弹窗 */}
|
||||
<TagEditorDialog
|
||||
visible={tagEditVisible}
|
||||
onClose={() => {
|
||||
setTagEditVisible(false)
|
||||
setEditingStudent(null)
|
||||
}}
|
||||
onConfirm={handleSaveTags}
|
||||
initialTagIds={editingStudent?.tagIds || []}
|
||||
title={`编辑标签 - ${editingStudent?.name || ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Dialog, Input, Button, Space, Tag, MessagePlugin } from 'tdesign-react'
|
||||
|
||||
interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TagEditorDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (tagIds: number[]) => void
|
||||
initialTagIds?: number[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
initialTagIds = [],
|
||||
title = '编辑标签'
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [allTags, setAllTags] = useState<Tag[]>([])
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTagIds(new Set(initialTagIds))
|
||||
setInputValue('')
|
||||
fetchAllTags()
|
||||
}
|
||||
}, [visible, initialTagIds])
|
||||
|
||||
const fetchAllTags = async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await (window as any).api.tagsGetAll()
|
||||
if (res.success && res.data) {
|
||||
setAllTags(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tags:', e)
|
||||
MessagePlugin.error('获取标签列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = async () => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
if (trimmed.length > 50) {
|
||||
MessagePlugin.error('标签名称不能超过 50 个字符')
|
||||
return
|
||||
}
|
||||
|
||||
if (allTags.some(t => t.name === trimmed)) {
|
||||
const existingTag = allTags.find(t => t.name === trimmed)
|
||||
if (existingTag) {
|
||||
setSelectedTagIds(prev => new Set([...prev, existingTag.id]))
|
||||
}
|
||||
setInputValue('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.tagsCreate(trimmed)
|
||||
if (res.success && res.data) {
|
||||
setAllTags(prev => [...prev, res.data])
|
||||
setSelectedTagIds(prev => new Set([...prev, res.data.id]))
|
||||
setInputValue('')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '添加标签失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create tag:', e)
|
||||
MessagePlugin.error('添加标签失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTag = (tagId: number) => {
|
||||
setSelectedTagIds(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(tagId)) {
|
||||
newSet.delete(tagId)
|
||||
} else {
|
||||
newSet.add(tagId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteTag = async (tagId: number) => {
|
||||
try {
|
||||
const res = await (window as any).api.tagsDelete(tagId)
|
||||
if (res.success) {
|
||||
setAllTags(prev => prev.filter(t => t.id !== tagId))
|
||||
setSelectedTagIds(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(tagId)
|
||||
return newSet
|
||||
})
|
||||
MessagePlugin.success('标签删除成功')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除标签失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete tag:', e)
|
||||
MessagePlugin.error('删除标签失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (value: string, context: { e: React.KeyboardEvent<HTMLInputElement> }) => {
|
||||
if (context.e.key === 'Enter') {
|
||||
context.e.preventDefault()
|
||||
handleAddTag()
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(Array.from(selectedTagIds))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const selectedTags = allTags.filter(t => selectedTagIds.has(t.id))
|
||||
const availableTags = allTags.filter(t => !selectedTagIds.has(t.id))
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={title}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
confirmBtn={{ content: '保存', theme: 'primary' }}
|
||||
cancelBtn={{ content: '取消' }}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* 标签输入区 */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Input
|
||||
placeholder="输入标签名称,按 Enter 添加"
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onEnter={handleAddTag}
|
||||
/* maxLength={50} */
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleAddTag}
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 已选标签区 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
已选标签(点击取消)
|
||||
</div>
|
||||
<div style={{ minHeight: '50px', padding: '12px', backgroundColor: 'var(--ss-card-bg)', borderRadius: '4px' }}>
|
||||
{selectedTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>未选择标签</span>
|
||||
) : (
|
||||
<Space>
|
||||
{selectedTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
theme="primary"
|
||||
variant="light"
|
||||
closable
|
||||
onClose={() => handleToggleTag(tag.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可选标签区 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
可选标签(点击选择)
|
||||
</div>
|
||||
<div style={{ minHeight: '50px', padding: '12px', backgroundColor: 'var(--ss-card-bg)', borderRadius: '4px' }}>
|
||||
{availableTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>无可用标签</span>
|
||||
) : (
|
||||
<Space>
|
||||
{availableTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
theme="default"
|
||||
variant="outline"
|
||||
closable
|
||||
onClose={() => handleDeleteTag(tag.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user