diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..edbf742 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,195 @@ +name: build + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + version: + description: "版本号(例如 1.2.3 或 v1.2.3)" + required: false + build: + description: "构建命令标记(build:win|build:mac|build:linux|build:unpack|build:all)" + required: false + +permissions: + contents: write + +jobs: + parse: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + tag: ${{ steps.parse.outputs.tag }} + build_script: ${{ steps.parse.outputs.build_script }} + run_win: ${{ steps.parse.outputs.run_win }} + run_mac: ${{ steps.parse.outputs.run_mac }} + run_linux: ${{ steps.parse.outputs.run_linux }} + run_unpack: ${{ steps.parse.outputs.run_unpack }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 读取提交信息 + id: msg + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + { + echo "message<<__EOF__" + echo "${{ github.event.inputs.version }} ${{ github.event.inputs.build }}" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" + else + { + echo "message<<__EOF__" + echo "${{ github.event.head_commit.message }}" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: 解析版本与构建命令 + id: parse + env: + RELEASE_MESSAGE: ${{ steps.msg.outputs.message }} + run: node scripts/ci/parse-commit.mjs + + build_win: + needs: parse + if: ${{ needs.parse.outputs.run_win == 'true' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: 应用版本号 + run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }} + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建(Windows) + run: pnpm -s build:win + + - uses: actions/upload-artifact@v4 + with: + name: dist-win + path: dist/** + + build_mac: + needs: parse + if: ${{ needs.parse.outputs.run_mac == 'true' }} + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: 应用版本号 + run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }} + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建(macOS) + run: pnpm -s build:mac + + - uses: actions/upload-artifact@v4 + with: + name: dist-mac + path: dist/** + + build_linux: + needs: parse + if: ${{ needs.parse.outputs.run_linux == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: 应用版本号 + run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }} + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建(Linux) + run: pnpm -s build:linux + + - uses: actions/upload-artifact@v4 + with: + name: dist-linux + path: dist/** + + build_unpack: + needs: parse + if: ${{ needs.parse.outputs.run_unpack == 'true' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: 应用版本号 + run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }} + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建(dir) + run: pnpm -s build:unpack + + - uses: actions/upload-artifact@v4 + with: + name: dist-unpack + path: dist/** + + release: + needs: [parse, build_win, build_mac, build_linux, build_unpack] + if: ${{ always() && needs.parse.outputs.tag != '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: 发布 Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.parse.outputs.tag }} + name: SecScore ${{ needs.parse.outputs.tag }} + draft: false + prerelease: ${{ contains(needs.parse.outputs.version, '-') }} + files: | + artifacts/**/* diff --git a/electron-builder.yml b/electron-builder.yml index 553ee4c..00bc25c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -42,7 +42,6 @@ dmg: linux: target: - AppImage - - snap - deb maintainer: electronjs.org category: Utility diff --git a/eslint.config.mjs b/eslint.config.mjs index d60f3c3..bab70ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks' import eslintPluginReactRefresh from 'eslint-plugin-react-refresh' export default defineConfig( - { ignores: ['**/node_modules', '**/dist', '**/out', 'scripts/**'] }, + { ignores: ['**/node_modules', '**/dist', '**/out', 'scripts/**', 'secrandom_ipc_send_url.js'] }, tseslint.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReact.configs.flat['jsx-runtime'], diff --git a/scripts/ci/apply-version.mjs b/scripts/ci/apply-version.mjs new file mode 100644 index 0000000..87606b2 --- /dev/null +++ b/scripts/ci/apply-version.mjs @@ -0,0 +1,20 @@ +import fs from 'fs' +import path from 'path' + +const version = String(process.argv[2] || '').trim().replace(/^v/i, '') +if (!version) { + process.stderr.write('缺少版本号参数,例如:node scripts/ci/apply-version.mjs 1.2.3\n') + process.exit(1) +} + +const semverRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ +if (!semverRe.test(version)) { + process.stderr.write(`非法版本号:${version}\n`) + process.exit(1) +} + +const pkgPath = path.join(process.cwd(), 'package.json') +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) +pkg.version = version +fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') + diff --git a/scripts/ci/parse-commit.mjs b/scripts/ci/parse-commit.mjs new file mode 100644 index 0000000..6ff5598 --- /dev/null +++ b/scripts/ci/parse-commit.mjs @@ -0,0 +1,91 @@ +import fs from 'fs' +import path from 'path' + +const cwd = process.cwd() +const pkgPath = path.join(cwd, 'package.json') +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) +const scripts = pkg?.scripts ?? {} + +const rawMessage = + process.env.RELEASE_MESSAGE ?? + process.env.GITHUB_COMMIT_MESSAGE ?? + process.argv.slice(2).join(' ') ?? + '' +const message = String(rawMessage || '').trim() + +const semverRe = /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/ +const versionMatch = message.match(semverRe) +const version = versionMatch ? versionMatch[1] : '' + +const buildToken = + message.match(/\b(build:(?:win|mac|linux|unpack|all))\b/i)?.[1]?.toLowerCase() ?? '' +const buildScript = + buildToken === 'build:all' + ? 'build:all' + : buildToken && buildToken.startsWith('build:') + ? buildToken + : '' + +const supportedBuildScripts = new Set(['build:win', 'build:mac', 'build:linux', 'build:unpack']) +const hasBuildScript = buildScript + ? buildScript === 'build:all' + ? [...supportedBuildScripts].every((s) => typeof scripts[s] === 'string' && scripts[s]) + : typeof scripts[buildScript] === 'string' && scripts[buildScript] + : false + +const fail = (text) => { + process.stderr.write(`${text}\n`) + process.exit(1) +} + +const hasAnySignal = Boolean(version || buildScript) +const shouldSkip = !hasAnySignal + +if (!shouldSkip) { + if (!version) { + fail( + [ + '未在提交信息中检测到版本号。', + '示例:release: v1.2.3 build:win', + '支持格式:v1.2.3 或 1.2.3(可带 -beta.1 等后缀)' + ].join('\n') + ) + } + + if (!buildScript) { + fail( + [ + '未在提交信息中检测到构建命令标记。', + '示例:release: v1.2.3 build:win', + '支持:build:win | build:mac | build:linux | build:unpack | build:all' + ].join('\n') + ) + } + + if (!hasBuildScript) { + fail(`构建脚本不存在或为空:${buildScript}`) + } +} + +const runWin = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:win') +const runMac = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:mac') +const runLinux = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:linux') +const runUnpack = !shouldSkip && buildScript === 'build:unpack' + +const outputs = { + version: shouldSkip ? '' : version, + tag: shouldSkip ? '' : `v${version}`, + build_script: shouldSkip ? '' : buildScript, + run_win: String(runWin), + run_mac: String(runMac), + run_linux: String(runLinux), + run_unpack: String(runUnpack) +} + +if (process.env.GITHUB_OUTPUT) { + const lines = Object.entries(outputs).map(([k, v]) => `${k}=${v}`) + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`) +} else { + process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`) +} + diff --git a/secrandom_ipc_send_url.js b/secrandom_ipc_send_url.js new file mode 100644 index 0000000..3eb6dd1 --- /dev/null +++ b/secrandom_ipc_send_url.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * SecRandom IPC URL 发送脚本 (Node.js 版本) + * + * 用途 + * - 向本机正在运行的 SecRandom 实例发送 URL(例如 SecRandom://settings) + * - 通过“命名 IPC 通道”连接(Windows 命名管道 / Linux socket),无需端口 + * + * 前提 + * - SecRandom 主程序已启动,并已启动 URL IPC 服务端 + * + * 基本用法(Windows / Linux 通用) + * - 发送打开设置页: + * node secrandom_ipc_send_url.js "SecRandom://settings" + * - 发送切换页面: + * node secrandom_ipc_send_url.js "SecRandom://main/lottery" + * + * 参数说明 + * - url:要发送的 URL(大小写不敏感) + * --ipc-name:目标通道名,默认 SecRandom.secrandom + * - Windows 对应:\\.\pipe\SecRandom.secrandom + * - Linux 对应:/tmp/SecRandom.secrandom.sock + * + * 返回与退出码 + * - 输出:打印服务端响应 JSON + * - 退出码: + * - 0:success 为 true + * - 2:success 为 false(例如命令不支持/被拒绝/服务端返回失败) + * - 1:脚本运行异常(例如连接失败、序列化失败等) + */ + +const net = require('net') + +function parseArgs(argv) { + const out = { url: '', ipcName: 'SecRandom.secrandom', timeout: 5000 } + const args = Array.from(argv) + for (let i = 0; i < args.length; i++) { + const a = String(args[i] ?? '') + if (a === '--help' || a === '-h') return { help: true } + + if (a === '--ipc-name') { + out.ipcName = String(args[i + 1] ?? '').trim() || out.ipcName + i++ + continue + } + if (a.startsWith('--ipc-name=')) { + out.ipcName = a.slice('--ipc-name='.length).trim() || out.ipcName + continue + } + + if (a === '--timeout') { + const n = Number.parseInt(String(args[i + 1] ?? ''), 10) + if (Number.isFinite(n) && n > 0) out.timeout = n + i++ + continue + } + if (a.startsWith('--timeout=')) { + const n = Number.parseInt(a.slice('--timeout='.length), 10) + if (Number.isFinite(n) && n > 0) out.timeout = n + continue + } + + if (!a.startsWith('-') && !out.url) { + out.url = a + } + } + return out +} + +const parsed = parseArgs(process.argv.slice(2)) +if (parsed.help || !parsed.url) { + console.log( + 'Usage: node secrandom_ipc_send_url.js [--ipc-name ] [--timeout ]\n' + + 'Example: node secrandom_ipc_send_url.js "SecRandom://settings"' + ) + process.exit(parsed.help ? 0 : 1) +} + +const { url, ipcName, timeout } = parsed + +function getIpcAddress(ipcName) { + if (process.platform === 'win32') { + return `\\\\.\\pipe\\${ipcName}` + } + return `/tmp/${ipcName}.sock` +} + +const ipcAddress = getIpcAddress(ipcName) +const message = JSON.stringify({ type: 'url', payload: { url } }) + +const client = net.createConnection(ipcAddress) + +client.setTimeout(parseInt(timeout)) + +client.on('connect', () => { + client.write(message + '\n') +}) + +client.on('data', (data) => { + try { + const response = JSON.parse(data.toString()) + console.log(JSON.stringify(response, null, 2)) + process.exit(response.success ? 0 : 2) + } catch (error) { + console.error('Failed to parse response:', error) + process.exit(1) + } +}) + +client.on('timeout', () => { + console.error('Connection timeout') + client.destroy() + process.exit(1) +}) + +client.on('error', (error) => { + if (error.code === 'ENOENT') { + console.error(`IPC 通道不存在: ${ipcAddress}. 请确认 SecRandom 已运行且已启动 IPC 服务端。`) + } else { + console.error('IPC send failed:', error.message) + } + process.exit(1) +}) + +client.on('close', () => { + if (process.exitCode === undefined) { + console.error('Connection closed without response') + process.exit(1) + } +}) diff --git a/src/main/repos/StudentRepository.ts b/src/main/repos/StudentRepository.ts index d436731..cec0755 100644 --- a/src/main/repos/StudentRepository.ts +++ b/src/main/repos/StudentRepository.ts @@ -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 { @@ -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((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((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 } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5eb97ae..30ffdfc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/src/components/ScoreManager.tsx b/src/renderer/src/components/ScoreManager.tsx index 7d943cd..7dfd17d 100644 --- a/src/renderer/src/components/ScoreManager.tsx +++ b/src/renderer/src/components/ScoreManager.tsx @@ -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 } diff --git a/src/renderer/src/components/StudentManager.tsx b/src/renderer/src/components/StudentManager.tsx index 4364cb3..2561a4b 100644 --- a/src/renderer/src/components/StudentManager.tsx +++ b/src/renderer/src/components/StudentManager.tsx @@ -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([]) 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([]) + const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null) + const xlsxInputRef = useRef(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[] = [ + { 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: ( + setXlsxSelectedCol(c)} + > + {excelColName(c)} + + ), + minWidth: 120 + }) + } + return cols + }, [xlsxMaxCols, xlsxSelectedCol]) + + const extractNamesFromAoa = (aoa: any[][], colIdx: number) => { + const out: string[] = [] + const seen = new Set() + 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[] = [ { colKey: 'name', title: '姓名', width: 200 }, { @@ -149,9 +332,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {

学生管理

- + + + +
= ({ canEdit }) => { + + setImportVisible(false)} + footer={false} + destroyOnClose + > + + + + { + const file = e.target.files?.[0] + if (file) parseXlsxFile(file) + if (xlsxInputRef.current) xlsxInputRef.current.value = '' + }} + /> + + + + setXlsxVisible(false)} + confirmBtn={{ content: '导入', loading: xlsxLoading, disabled: xlsxSelectedCol == null }} + onConfirm={handleConfirmXlsxImport} + width="80%" + destroyOnClose + > +
+
文件:{xlsxFileName || '-'}
+
+ 点击表头选择姓名列:{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)} +
+
预览前 50 行
+
+
+ ) }