Merge pull request #9 from NanGua-QWQ/dev

feat:现在可以添加学生Tag
This commit is contained in:
NanGua_QWQ
2026-02-07 23:00:51 +08:00
committed by GitHub
19 changed files with 647 additions and 20 deletions
+8 -8
View File
@@ -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
+13 -1
View File
@@ -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[]
}
+27
View File
@@ -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
}
+16
View File
@@ -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
}
+2
View File
@@ -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)
)
+2 -1
View File
@@ -1,3 +1,4 @@
import { InitSchema2026011800000 } from './InitSchema2026011800000'
import { AddTagsSchema2026020600000 } from './AddTagsSchema2026020600000'
export const migrations = [InitSchema2026011800000]
export const migrations = [InitSchema2026011800000, AddTagsSchema2026020600000]
+1
View File
@@ -12,6 +12,7 @@ export {
ReasonRepositoryToken,
EventRepositoryToken,
SettlementRepositoryToken,
TagRepositoryToken,
ThemeServiceToken,
WindowManagerToken,
TrayServiceToken,
+1
View File
@@ -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
View File
@@ -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,
+27 -1
View File
@@ -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
}))
+79
View File
@@ -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)
}
}
}
+57 -1
View File
@@ -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 }
})
}
}
+8
View File
@@ -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),
+7
View File
@@ -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>>
+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>
)
}
File diff suppressed because one or more lines are too long