feat: 添加学生名单导入功能并优化构建流程

- 新增从 SecRandom 和 xlsx 导入学生名单功能
- 添加 IPC 通信模块用于与 SecRandom 交互
- 实现 xlsx 文件解析和姓名列选择功能
- 优化构建流程,添加 CI/CD 相关脚本
- 移除 snap 构建目标
- 更新 ESLint 配置忽略新增脚本文件
This commit is contained in:
Fox_block
2026-01-18 15:32:40 +08:00
parent e83f17af56
commit 8c48b35c08
10 changed files with 966 additions and 7 deletions
+270
View File
@@ -1,6 +1,7 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { ScoreEventEntity, StudentEntity } from '../db/entities'
import net from 'net'
export interface student {
id: number
@@ -47,6 +48,82 @@ export class StudentRepository extends Service {
await this.delete(id)
return { success: true }
})
this.mainCtx.handle('db:student:importFromSecRandom', async (event, input: any) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const ipcName =
typeof input?.ipcName === 'string' && input.ipcName.trim()
? input.ipcName.trim()
: 'SecRandom.secrandom'
const timeoutMs =
typeof input?.timeoutMs === 'number' &&
Number.isFinite(input.timeoutMs) &&
input.timeoutMs > 0
? Math.floor(input.timeoutMs)
: 5000
let resp: any
try {
resp = await sendSecRandomIpcJson(
{
type: 'url',
payload: { url: 'SecRandom://secscore/importRoster' }
},
{ ipcName, timeoutMs }
)
} catch (e: any) {
const socketPath = getSecRandomIpcPath(ipcName)
const detail = e instanceof Error ? e.message : String(e)
return {
success: false,
message: `SecRandom IPC 连接失败:${detail}${socketPath}`,
data: { error: detail, socketPath, ipcName }
}
}
if (!resp?.success) {
return {
success: false,
message: resp?.message ? String(resp.message) : 'SecRandom IPC 返回失败',
data: { raw: resp }
}
}
const roster = parseSecRandomRoster(resp)
if (!roster.items.length) {
return {
success: false,
message: roster.message || '未在 SecRandom 返回中解析到名单',
data: { raw: resp }
}
}
const result = await this.importRosterMerge(roster.items)
return {
success: true,
data: {
className: roster.className,
sourceMessage: roster.message,
...result,
raw: resp
}
}
})
this.mainCtx.handle('db:student:importFromXlsx', async (event, input: any) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const rawNames = Array.isArray(input?.names) ? input.names : []
const names = rawNames.map((n: any) => String(n ?? '').trim()).filter((n: string) => n)
if (!names.length) return { success: false, message: '名单为空' }
const result = await this.importRosterMerge(
names.map((name) => ({ name, secrandomId: null }))
)
return { success: true, data: result }
})
}
async findAll(): Promise<student[]> {
@@ -87,4 +164,197 @@ export class StudentRepository extends Service {
await studentsRepo.delete({ id })
})
}
private async importRosterMerge(items: Array<{ name: string; secrandomId: number | null }>) {
const cleaned = Array.from(
new Map(
items
.map((i) => ({
name: String(i?.name ?? '').trim(),
secrandomId: Number.isFinite(i?.secrandomId as any) ? Number(i.secrandomId) : null
}))
.filter((i) => i.name && i.name.length <= 64)
.map((i) => [i.name, i] as const)
).values()
)
if (!cleaned.length) return { inserted: 0, skipped: 0, total: 0 }
const ds = this.ctx.db.dataSource
return await ds.transaction(async (manager) => {
const repo = manager.getRepository(StudentEntity)
const existing = await repo.find({ select: ['name'] as any })
const existingSet = new Set(existing.map((r: any) => String(r?.name ?? '').trim()))
const toInsert = cleaned.filter((i) => !existingSet.has(i.name))
const now = new Date().toISOString()
if (toInsert.length) {
await repo.insert(
toInsert.map((i) => ({
name: i.name,
score: 0,
extra_json:
typeof i.secrandomId === 'number'
? JSON.stringify({ secrandom_id: i.secrandomId })
: null,
created_at: now,
updated_at: now
}))
)
}
return {
inserted: toInsert.length,
skipped: cleaned.length - toInsert.length,
total: cleaned.length
}
})
}
}
function getSecRandomIpcPath(ipcName: string) {
if (process.platform === 'win32') return `\\\\.\\pipe\\${ipcName}`
return `/tmp/${ipcName}.sock`
}
async function connectSocket(path: string, timeoutMs: number) {
return await new Promise<net.Socket>((resolve, reject) => {
const socket = net.connect(path)
const onError = (err: any) => {
cleanup()
reject(err)
}
const onConnect = () => {
cleanup()
resolve(socket)
}
const timer = setTimeout(() => {
cleanup()
try {
socket.destroy(new Error('connect timeout'))
} catch {
void 0
}
reject(new Error('connect timeout'))
}, timeoutMs)
const cleanup = () => {
clearTimeout(timer)
socket.removeListener('error', onError)
socket.removeListener('connect', onConnect)
}
socket.once('error', onError)
socket.once('connect', onConnect)
})
}
async function recvJsonLine(socket: net.Socket, timeoutMs: number) {
return await new Promise<any>((resolve, reject) => {
let acc = ''
let settled = false
const maxChars = 2_000_000
const timer = setTimeout(() => {
cleanup()
reject(new Error('read timeout'))
}, timeoutMs)
const cleanup = () => {
if (settled) return
settled = true
clearTimeout(timer)
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('end', onEnd)
socket.removeListener('close', onClose)
}
const tryParse = (s: string) => {
const trimmed = s.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed)
} catch {
return null
}
}
const finishIfParsed = (raw: string) => {
const parsed = tryParse(raw)
if (parsed == null) return false
cleanup()
resolve(parsed)
return true
}
const onData = (chunk: Buffer) => {
acc += chunk.toString('utf8')
if (acc.length > maxChars) {
cleanup()
reject(new Error('response too large'))
return
}
const newlineIdx = acc.indexOf('\n')
if (newlineIdx >= 0) {
const line = acc.slice(0, newlineIdx)
if (finishIfParsed(line)) return
}
finishIfParsed(acc)
}
const onError = (err: any) => {
cleanup()
reject(err)
}
const onEnd = () => {
if (finishIfParsed(acc)) return
cleanup()
reject(new Error('connection closed without response'))
}
const onClose = () => {
if (settled) return
if (finishIfParsed(acc)) return
cleanup()
reject(new Error('connection closed without response'))
}
socket.on('data', onData)
socket.once('error', onError)
socket.once('end', onEnd)
socket.once('close', onClose)
})
}
async function sendSecRandomIpcJson(message: any, opts: { ipcName: string; timeoutMs: number }) {
const socketPath = getSecRandomIpcPath(opts.ipcName)
const socket = await connectSocket(socketPath, opts.timeoutMs)
try {
socket.write(JSON.stringify(message) + '\n')
return await recvJsonLine(socket, opts.timeoutMs)
} finally {
socket.end()
}
}
function parseSecRandomRoster(resp: any) {
const className = String(resp?.result?.class_name ?? '').trim()
const message = String(resp?.result?.message ?? '').trim()
const data = resp?.result?.data
const items: Array<{ name: string; secrandomId: number | null }> = []
if (Array.isArray(data)) {
for (const row of data) {
if (!row || typeof row !== 'object') continue
if (row.exist !== true) continue
const name = String((row as any).name ?? '').trim()
if (!name) continue
const idRaw = (row as any).id
const secrandomId = Number.isFinite(idRaw) ? Number(idRaw) : null
items.push({ name, secrandomId })
}
}
return { className, message, items }
}
+4
View File
@@ -18,6 +18,10 @@ const api = {
createStudent: (data: any) => ipcRenderer.invoke('db:student:create', data),
updateStudent: (id: number, data: any) => ipcRenderer.invoke('db:student:update', id, data),
deleteStudent: (id: number) => ipcRenderer.invoke('db:student:delete', id),
importStudentsFromSecRandom: (params?: { ipcName?: string; timeoutMs?: number }) =>
ipcRenderer.invoke('db:student:importFromSecRandom', params),
importStudentsFromXlsx: (params: { names: string[] }) =>
ipcRenderer.invoke('db:student:importFromXlsx', params),
// DB - Reason
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
+2 -1
View File
@@ -277,7 +277,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
if (hasCurrentDelta) {
form.setFieldsValue({
reason_content: reason.content
reason_content: reason.content,
type: reason.delta > 0 ? 'add' : 'subtract'
})
return
}
+253 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react'
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
import * as XLSX from 'xlsx'
interface student {
id: number
@@ -12,6 +13,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<student[]>([])
const [loading, setLoading] = useState(false)
const [visible, setVisible] = useState(false)
const [importVisible, setImportVisible] = useState(false)
const [importLoading, setImportLoading] = useState(false)
const [xlsxVisible, setXlsxVisible] = useState(false)
const [xlsxLoading, setXlsxLoading] = useState(false)
const [xlsxFileName, setXlsxFileName] = useState('')
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
const [form] = Form.useForm()
const emitDataUpdated = (category: 'students' | 'all') => {
@@ -103,6 +112,180 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
const handleImportFromSecRandom = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
setImportLoading(true)
try {
let res: any
try {
res = await (window as any).api.importStudentsFromSecRandom({})
} catch (e: any) {
MessagePlugin.error(e instanceof Error ? e.message : '导入失败')
return
}
if (!res?.success) {
MessagePlugin.error(res?.message || '导入失败')
return
}
const inserted = Number(res?.data?.inserted ?? 0)
const skipped = Number(res?.data?.skipped ?? 0)
const className = String(res?.data?.className ?? '').trim()
const sourceMessage = String(res?.data?.sourceMessage ?? '').trim()
const prefix = className ? `导入完成(${className}):` : '导入完成:'
const suffix = sourceMessage ? `${sourceMessage}` : ''
MessagePlugin.success(`${prefix}新增 ${inserted},跳过 ${skipped}${suffix}`)
setImportVisible(false)
fetchStudents()
emitDataUpdated('students')
} finally {
setImportLoading(false)
}
}
const excelColName = (idx: number) => {
let n = idx + 1
let s = ''
while (n > 0) {
const mod = (n - 1) % 26
s = String.fromCharCode(65 + mod) + s
n = Math.floor((n - 1) / 26)
}
return s
}
const parseXlsxFile = async (file: File) => {
setXlsxLoading(true)
try {
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const firstSheetName = wb.SheetNames?.[0]
if (!firstSheetName) {
MessagePlugin.error('xlsx 中未找到工作表')
return
}
const ws = wb.Sheets[firstSheetName]
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][]
if (!Array.isArray(aoa) || aoa.length === 0) {
MessagePlugin.error('xlsx 内容为空')
return
}
setXlsxFileName(file.name)
setXlsxAoa(aoa)
setXlsxSelectedCol(null)
setXlsxVisible(true)
setImportVisible(false)
} catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败')
} finally {
setXlsxLoading(false)
}
}
const xlsxMaxCols = useMemo(() => {
let max = 0
for (const row of xlsxAoa) {
if (Array.isArray(row)) max = Math.max(max, row.length)
}
return max
}, [xlsxAoa])
const xlsxPreviewRows = useMemo(() => {
const limit = 50
const rows = xlsxAoa.slice(0, limit)
return rows.map((row, idx) => {
const record: any = { __row: idx + 1 }
for (let c = 0; c < xlsxMaxCols; c++) {
record[`c${c}`] = row?.[c] ?? ''
}
return record
})
}, [xlsxAoa, xlsxMaxCols])
const xlsxPreviewColumns = useMemo(() => {
const cols: PrimaryTableCol<any>[] = [
{ colKey: '__row', title: '#', width: 60, align: 'center', fixed: 'left' as any }
]
for (let c = 0; c < xlsxMaxCols; c++) {
const selected = xlsxSelectedCol === c
cols.push({
colKey: `c${c}`,
title: (
<span
style={{
cursor: 'pointer',
fontWeight: selected ? 700 : 500,
color: selected ? 'var(--td-brand-color)' : undefined
}}
onClick={() => setXlsxSelectedCol(c)}
>
{excelColName(c)}
</span>
),
minWidth: 120
})
}
return cols
}, [xlsxMaxCols, xlsxSelectedCol])
const extractNamesFromAoa = (aoa: any[][], colIdx: number) => {
const out: string[] = []
const seen = new Set<string>()
const banned = new Set(['姓名', 'name', '名字'])
for (const row of aoa) {
const raw = row?.[colIdx]
const name = String(raw ?? '').trim()
if (!name) continue
if (banned.has(name.toLowerCase()) || banned.has(name)) continue
if (seen.has(name)) continue
seen.add(name)
out.push(name)
}
return out
}
const handleConfirmXlsxImport = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
if (xlsxSelectedCol == null) {
MessagePlugin.warning('请先点击选择“姓名列”')
return
}
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
if (!names.length) {
MessagePlugin.error('所选列未解析到可导入的姓名')
return
}
setXlsxLoading(true)
try {
const res = await (window as any).api.importStudentsFromXlsx({ names })
if (!res?.success) {
MessagePlugin.error(res?.message || '导入失败')
return
}
const inserted = Number(res?.data?.inserted ?? 0)
const skipped = Number(res?.data?.skipped ?? 0)
MessagePlugin.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
setXlsxVisible(false)
setXlsxAoa([])
setXlsxFileName('')
setXlsxSelectedCol(null)
fetchStudents()
emitDataUpdated('students')
} finally {
setXlsxLoading(false)
}
}
const columns: PrimaryTableCol<student>[] = [
{ colKey: 'name', title: '姓名', width: 200 },
{
@@ -149,9 +332,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
<Space>
<Button variant="outline" disabled={!canEdit} onClick={() => setImportVisible(true)}>
</Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</Space>
</div>
<Table
@@ -178,6 +366,67 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Form.FormItem>
</Form>
</Dialog>
<Dialog
header="导入名单"
visible={importVisible}
onClose={() => setImportVisible(false)}
footer={false}
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={importLoading} disabled={!canEdit} onClick={handleImportFromSecRandom}>
SecRandomIPC
</Button>
<Button
loading={xlsxLoading}
disabled={!canEdit}
onClick={() => {
xlsxInputRef.current?.click()
}}
>
xlsx
</Button>
<input
ref={xlsxInputRef}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) parseXlsxFile(file)
if (xlsxInputRef.current) xlsxInputRef.current.value = ''
}}
/>
</Space>
</Dialog>
<Dialog
header="xlsx 预览与导入"
visible={xlsxVisible}
onClose={() => setXlsxVisible(false)}
confirmBtn={{ content: '导入', loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
onConfirm={handleConfirmXlsxImport}
width="80%"
destroyOnClose
>
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
<div>{xlsxFileName || '-'}</div>
<div>
{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
</div>
<div> 50 </div>
</div>
<Table
data={xlsxPreviewRows}
columns={xlsxPreviewColumns}
rowKey="__row"
bordered
hover
maxHeight={420}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
</Dialog>
</div>
)
}