mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
@@ -2,13 +2,13 @@ import { Context, Service } from '../../shared/kernel'
|
||||
import { DataSource } from 'typeorm'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
StudentEntity
|
||||
} from './entities'
|
||||
import { StudentEntity } from './entities/StudentEntity'
|
||||
import { ReasonEntity } from './entities/ReasonEntity'
|
||||
import { ScoreEventEntity } from './entities/ScoreEventEntity'
|
||||
import { SettlementEntity } from './entities/SettlementEntity'
|
||||
import { SettingEntity } from './entities/SettingEntity'
|
||||
import { TagEntity } from './entities/TagEntity'
|
||||
import { StudentTagEntity } from './entities/StudentTagEntity'
|
||||
import { migrations } from './migrations'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
@@ -29,7 +29,7 @@ export class DbManager extends Service {
|
||||
this.dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: dbPath,
|
||||
entities: [StudentEntity, ReasonEntity, ScoreEventEntity, SettlementEntity, SettingEntity],
|
||||
entities: [StudentEntity, ReasonEntity, ScoreEventEntity, SettlementEntity, SettingEntity, TagEntity, StudentTagEntity],
|
||||
migrations,
|
||||
synchronize: false,
|
||||
logging: false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
import { Column, Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm'
|
||||
import { TagEntity } from './TagEntity'
|
||||
|
||||
@Entity({ name: 'students' })
|
||||
export class StudentEntity {
|
||||
@@ -8,6 +9,9 @@ export class StudentEntity {
|
||||
@Column({ type: 'text' })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text', default: '[]' })
|
||||
tags!: string
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
score!: number
|
||||
|
||||
@@ -19,4 +23,12 @@ export class StudentEntity {
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
|
||||
@ManyToMany(() => TagEntity, { cascade: true })
|
||||
@JoinTable({
|
||||
name: 'student_tags',
|
||||
joinColumn: { name: 'student_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' }
|
||||
})
|
||||
tagEntities!: TagEntity[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'
|
||||
import { StudentEntity } from './StudentEntity'
|
||||
import { TagEntity } from './TagEntity'
|
||||
|
||||
@Entity({ name: 'student_tags' })
|
||||
@Index(['student_id', 'tag_id'], { unique: true })
|
||||
export class StudentTagEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
student_id!: number
|
||||
|
||||
@Column({ name: 'tag_id', type: 'integer' })
|
||||
tag_id!: number
|
||||
|
||||
@ManyToOne(() => StudentEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student!: StudentEntity
|
||||
|
||||
@ManyToOne(() => TagEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tag_id' })
|
||||
tag!: TagEntity
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'tags' })
|
||||
export class TagEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text', unique: true })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export { ReasonEntity } from './ReasonEntity'
|
||||
export { ScoreEventEntity } from './ScoreEventEntity'
|
||||
export { SettlementEntity } from './SettlementEntity'
|
||||
export { SettingEntity } from './SettingEntity'
|
||||
export { TagEntity } from './TagEntity'
|
||||
export { StudentTagEntity } from './StudentTagEntity'
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class AddTagsSchema2026020600000 implements MigrationInterface {
|
||||
name = 'AddTagsSchema2026020600000'
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('tags'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "tags" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"name" text NOT NULL UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('student_tags'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "student_tags" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"student_id" integer NOT NULL,
|
||||
"tag_id" integer NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY ("student_id") REFERENCES "students"("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE,
|
||||
UNIQUE("student_id", "tag_id")
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_student_id" ON "student_tags" ("student_id")`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
|
||||
)
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT OR IGNORE INTO "tags" ("name") VALUES
|
||||
('优秀生'),
|
||||
('班干部'),
|
||||
('勤奋'),
|
||||
('活跃'),
|
||||
('进步快')
|
||||
`)
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "student_tags"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "tags"`)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export class InitSchema2026011800000 implements MigrationInterface {
|
||||
"name" text NOT NULL,
|
||||
"score" integer NOT NULL DEFAULT (0),
|
||||
"extra_json" text,
|
||||
"tags" text NOT NULL DEFAULT '[]',
|
||||
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InitSchema2026011800000 } from './InitSchema2026011800000'
|
||||
import { AddTagsSchema2026020600000 } from './AddTagsSchema2026020600000'
|
||||
|
||||
export const migrations = [InitSchema2026011800000]
|
||||
export const migrations = [InitSchema2026011800000, AddTagsSchema2026020600000]
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
ReasonRepositoryToken,
|
||||
EventRepositoryToken,
|
||||
SettlementRepositoryToken,
|
||||
TagRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
|
||||
@@ -10,6 +10,7 @@ export const StudentRepositoryToken = Symbol.for('secscore.studentRepository')
|
||||
export const ReasonRepositoryToken = Symbol.for('secscore.reasonRepository')
|
||||
export const EventRepositoryToken = Symbol.for('secscore.eventRepository')
|
||||
export const SettlementRepositoryToken = Symbol.for('secscore.settlementRepository')
|
||||
export const TagRepositoryToken = Symbol.for('secscore.tagRepository')
|
||||
export const ThemeServiceToken = Symbol.for('secscore.themeService')
|
||||
export const WindowManagerToken = Symbol.for('secscore.windowManager')
|
||||
export const TrayServiceToken = Symbol.for('secscore.trayService')
|
||||
|
||||
+8
-2
@@ -13,6 +13,7 @@ import { PermissionService } from './services/PermissionService'
|
||||
import { AuthService } from './services/AuthService'
|
||||
import { DataService } from './services/DataService'
|
||||
import { ThemeService } from './services/ThemeService'
|
||||
import { HttpServerService } from './services/HttpServerService'
|
||||
import { WindowManager, type windowManagerOptions } from './services/WindowManager'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { AutoScoreService } from './services/AutoScoreService'
|
||||
@@ -20,6 +21,7 @@ import { StudentRepository } from './repos/StudentRepository'
|
||||
import { ReasonRepository } from './repos/ReasonRepository'
|
||||
import { EventRepository } from './repos/EventRepository'
|
||||
import { SettlementRepository } from './repos/SettlementRepository'
|
||||
import { TagRepository } from './repos/TagRepository'
|
||||
import {
|
||||
AppConfigToken,
|
||||
createHostBuilder,
|
||||
@@ -32,13 +34,13 @@ import {
|
||||
SettlementRepositoryToken,
|
||||
SettingsStoreToken,
|
||||
StudentRepositoryToken,
|
||||
TagRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
AutoScoreServiceToken,
|
||||
HttpServerServiceToken
|
||||
} from './hosting'
|
||||
import { HttpServerService } from './services/HttpServerService'
|
||||
|
||||
type mainAppConfig = {
|
||||
isDev: boolean
|
||||
@@ -230,7 +232,7 @@ app.whenReady().then(async () => {
|
||||
(p) => new PermissionService(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(AuthService, (p) => new AuthService(p.get(MainContext)))
|
||||
services.addSingleton(DataService, (p) => new DataService(p.get(MainContext)))
|
||||
services.addSingleton(DataService, (p) => new DataService(p.get(MainContext), p.get(TagRepositoryToken)))
|
||||
|
||||
services.addSingleton(
|
||||
StudentRepositoryToken,
|
||||
@@ -242,6 +244,10 @@ app.whenReady().then(async () => {
|
||||
SettlementRepositoryToken,
|
||||
(p) => new SettlementRepository(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(
|
||||
TagRepositoryToken,
|
||||
(p) => new TagRepository((p.get(DbManagerToken) as DbManager).dataSource)
|
||||
)
|
||||
|
||||
services.addSingleton(
|
||||
ThemeServiceToken,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
tags?: string[]
|
||||
extra_json?: string
|
||||
}
|
||||
|
||||
@@ -74,7 +75,26 @@ export class StudentRepository extends Service {
|
||||
|
||||
async findAll(): Promise<student[]> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
|
||||
return (await repo.find({ order: { score: 'DESC', name: 'ASC' } })) as any
|
||||
const entities = await repo.find({
|
||||
select: ['id', 'name', 'score', 'tags', 'extra_json', 'created_at', 'updated_at'],
|
||||
order: { score: 'DESC', name: 'ASC' }
|
||||
})
|
||||
|
||||
return entities.map(entity => {
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = Array.isArray(entity.tags) ? entity.tags : JSON.parse(entity.tags || '[]')
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
score: entity.score,
|
||||
tags,
|
||||
extra_json: entity.extra_json ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async create(student: { name: string }): Promise<number> {
|
||||
@@ -83,6 +103,7 @@ export class StudentRepository extends Service {
|
||||
name: String(student?.name ?? '').trim(),
|
||||
score: 0,
|
||||
extra_json: null,
|
||||
tags: '[]',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
@@ -94,7 +115,11 @@ export class StudentRepository extends Service {
|
||||
const next: any = {}
|
||||
for (const [key, val] of Object.entries(student)) {
|
||||
if (key === 'id') continue
|
||||
if (key === 'tags') {
|
||||
next[key] = JSON.stringify(val || [])
|
||||
} else {
|
||||
next[key] = val
|
||||
}
|
||||
}
|
||||
next.updated_at = new Date().toISOString()
|
||||
await this.ctx.db.dataSource.getRepository(StudentEntity).update(id, next)
|
||||
@@ -142,6 +167,7 @@ export class StudentRepository extends Service {
|
||||
typeof i.secrandomId === 'number'
|
||||
? JSON.stringify({ secrandom_id: i.secrandomId })
|
||||
: null,
|
||||
tags: '[]',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DataSource, Repository } from 'typeorm'
|
||||
import { TagEntity } from '../db/entities/TagEntity'
|
||||
import { StudentTagEntity } from '../db/entities/StudentTagEntity'
|
||||
|
||||
export class TagRepository {
|
||||
private tagRepo: Repository<TagEntity>
|
||||
private studentTagRepo: Repository<StudentTagEntity>
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.tagRepo = dataSource.getRepository(TagEntity)
|
||||
this.studentTagRepo = dataSource.getRepository(StudentTagEntity)
|
||||
}
|
||||
|
||||
async findAll(): Promise<TagEntity[]> {
|
||||
return this.tagRepo.find({ order: { created_at: 'ASC' } })
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<TagEntity | null> {
|
||||
return this.tagRepo.findOne({ where: { name } })
|
||||
}
|
||||
|
||||
async create(name: string): Promise<TagEntity> {
|
||||
const tag = this.tagRepo.create({ name })
|
||||
return this.tagRepo.save(tag)
|
||||
}
|
||||
|
||||
async findOrCreate(name: string): Promise<TagEntity> {
|
||||
const existing = await this.findByName(name)
|
||||
if (existing) return existing
|
||||
return this.create(name)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.tagRepo.delete(id)
|
||||
return result.affected === 1
|
||||
}
|
||||
|
||||
async findByStudent(studentId: number): Promise<TagEntity[]> {
|
||||
const relations = await this.studentTagRepo
|
||||
.createQueryBuilder('st')
|
||||
.leftJoinAndSelect('st.tag', 'tag')
|
||||
.where('st.student_id = :studentId', { studentId })
|
||||
.orderBy('st.created_at', 'ASC')
|
||||
.getMany()
|
||||
|
||||
return relations.map(r => r.tag).filter(Boolean)
|
||||
}
|
||||
|
||||
async addTagToStudent(studentId: number, tagId: number): Promise<void> {
|
||||
const exists = await this.studentTagRepo.findOne({
|
||||
where: { student_id: studentId, tag_id: tagId }
|
||||
})
|
||||
if (!exists) {
|
||||
const relation = this.studentTagRepo.create({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
await this.studentTagRepo.save(relation)
|
||||
}
|
||||
}
|
||||
|
||||
async removeTagFromStudent(studentId: number, tagId: number): Promise<void> {
|
||||
await this.studentTagRepo.delete({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
}
|
||||
|
||||
async updateStudentTags(studentId: number, tagIds: number[]): Promise<void> {
|
||||
await this.studentTagRepo.delete({ student_id: studentId })
|
||||
for (const tagId of tagIds) {
|
||||
const relation = this.studentTagRepo.create({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
await this.studentTagRepo.save(relation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { DataBackupRepository } from '../db/backup/DataBackupRepository'
|
||||
import { TagRepository } from '../repos/TagRepository'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
@@ -9,7 +10,10 @@ declare module '../../shared/kernel' {
|
||||
}
|
||||
|
||||
export class DataService extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
constructor(
|
||||
ctx: MainContext,
|
||||
private tagRepo: TagRepository
|
||||
) {
|
||||
super(ctx, 'data')
|
||||
this.registerIpc()
|
||||
}
|
||||
@@ -41,5 +45,57 @@ export class DataService extends Service {
|
||||
await this.mainCtx.settings.reloadFromDb()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:getAll', async (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'view'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tags = await this.tagRepo.findAll()
|
||||
return {
|
||||
success: true,
|
||||
data: tags.map(t => ({ id: t.id, name: t.name }))
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:getByStudent', async (event, studentId: number) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'view'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tags = await this.tagRepo.findByStudent(studentId)
|
||||
return {
|
||||
success: true,
|
||||
data: tags.map(t => ({ id: t.id, name: t.name }))
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:create', async (event, name: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tag = await this.tagRepo.findOrCreate(name)
|
||||
return {
|
||||
success: true,
|
||||
data: { id: tag.id, name: tag.name }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:delete', async (event, id: number) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const success = await this.tagRepo.delete(id)
|
||||
return {
|
||||
success,
|
||||
message: success ? '删除成功' : '删除失败'
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:updateStudentTags', async (event, studentId: number, tagIds: number[]) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
await this.tagRepo.updateStudentTags(studentId, tagIds)
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ const api = {
|
||||
importStudentsFromXlsx: (params: { names: string[] }) =>
|
||||
ipcRenderer.invoke('db:student:importFromXlsx', params),
|
||||
|
||||
// DB - Tags
|
||||
tagsGetAll: () => ipcRenderer.invoke('tags:getAll'),
|
||||
tagsGetByStudent: (studentId: number) => ipcRenderer.invoke('tags:getByStudent', studentId),
|
||||
tagsCreate: (name: string) => ipcRenderer.invoke('tags:create', name),
|
||||
tagsDelete: (id: number) => ipcRenderer.invoke('tags:delete', id),
|
||||
tagsUpdateStudentTags: (studentId: number, tagIds: number[]) =>
|
||||
ipcRenderer.invoke('tags:updateStudentTags', studentId, tagIds),
|
||||
|
||||
// DB - Reason
|
||||
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
|
||||
createReason: (data: any) => ipcRenderer.invoke('db:reason:create', data),
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface electronApi {
|
||||
updateStudent: (id: number, data: any) => Promise<ipcResponse<void>>
|
||||
deleteStudent: (id: number) => Promise<ipcResponse<void>>
|
||||
|
||||
// DB - Tags
|
||||
tagsGetAll: () => Promise<ipcResponse<{ id: number; name: string }[]>>
|
||||
tagsGetByStudent: (studentId: number) => Promise<ipcResponse<{ id: number; name: string }[]>>
|
||||
tagsCreate: (name: string) => Promise<ipcResponse<{ id: number; name: string }>>
|
||||
tagsDelete: (id: number) => Promise<ipcResponse<void>>
|
||||
tagsUpdateStudentTags: (studentId: number, tagIds: number[]) => Promise<ipcResponse<void>>
|
||||
|
||||
// DB - Reason
|
||||
queryReasons: () => Promise<ipcResponse<any[]>>
|
||||
createReason: (data: any) => Promise<ipcResponse<number>>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user