feat:学生Tag

This commit is contained in:
NanGua-QWQ
2026-02-07 21:15:57 +08:00
parent fa75b88f11
commit b6023dbe62
19 changed files with 647 additions and 20 deletions
+116 -5
View File
@@ -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>
)
}