mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 添加学生名单导入功能并优化构建流程
- 新增从 SecRandom 和 xlsx 导入学生名单功能 - 添加 IPC 通信模块用于与 SecRandom 交互 - 实现 xlsx 文件解析和姓名列选择功能 - 优化构建流程,添加 CI/CD 相关脚本 - 移除 snap 构建目标 - 更新 ESLint 配置忽略新增脚本文件
This commit is contained in:
@@ -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/**/*
|
||||||
@@ -42,7 +42,6 @@ dmg:
|
|||||||
linux:
|
linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- snap
|
|
||||||
- deb
|
- deb
|
||||||
maintainer: electronjs.org
|
maintainer: electronjs.org
|
||||||
category: Utility
|
category: Utility
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
|||||||
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
|
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
{ ignores: ['**/node_modules', '**/dist', '**/out', 'scripts/**'] },
|
{ ignores: ['**/node_modules', '**/dist', '**/out', 'scripts/**', 'secrandom_ipc_send_url.js'] },
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
eslintPluginReact.configs.flat.recommended,
|
eslintPluginReact.configs.flat.recommended,
|
||||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 <url> [--ipc-name <name>] [--timeout <ms>]\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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Service } from '../../shared/kernel'
|
import { Service } from '../../shared/kernel'
|
||||||
import { MainContext } from '../context'
|
import { MainContext } from '../context'
|
||||||
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
||||||
|
import net from 'net'
|
||||||
|
|
||||||
export interface student {
|
export interface student {
|
||||||
id: number
|
id: number
|
||||||
@@ -47,6 +48,82 @@ export class StudentRepository extends Service {
|
|||||||
await this.delete(id)
|
await this.delete(id)
|
||||||
return { success: true }
|
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[]> {
|
async findAll(): Promise<student[]> {
|
||||||
@@ -87,4 +164,197 @@ export class StudentRepository extends Service {
|
|||||||
await studentsRepo.delete({ id })
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const api = {
|
|||||||
createStudent: (data: any) => ipcRenderer.invoke('db:student:create', data),
|
createStudent: (data: any) => ipcRenderer.invoke('db:student:create', data),
|
||||||
updateStudent: (id: number, data: any) => ipcRenderer.invoke('db:student:update', id, data),
|
updateStudent: (id: number, data: any) => ipcRenderer.invoke('db:student:update', id, data),
|
||||||
deleteStudent: (id: number) => ipcRenderer.invoke('db:student:delete', id),
|
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
|
// DB - Reason
|
||||||
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
|
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
|
||||||
|
|||||||
@@ -277,7 +277,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
if (hasCurrentDelta) {
|
if (hasCurrentDelta) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
reason_content: reason.content
|
reason_content: reason.content,
|
||||||
|
type: reason.delta > 0 ? 'add' : 'subtract'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react'
|
import type { PrimaryTableCol } from 'tdesign-react'
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
|
|
||||||
interface student {
|
interface student {
|
||||||
id: number
|
id: number
|
||||||
@@ -12,6 +13,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [data, setData] = useState<student[]>([])
|
const [data, setData] = useState<student[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [visible, setVisible] = 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 [form] = Form.useForm()
|
||||||
|
|
||||||
const emitDataUpdated = (category: 'students' | 'all') => {
|
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>[] = [
|
const columns: PrimaryTableCol<student>[] = [
|
||||||
{ colKey: 'name', title: '姓名', width: 200 },
|
{ colKey: 'name', title: '姓名', width: 200 },
|
||||||
{
|
{
|
||||||
@@ -149,9 +332,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>学生管理</h2>
|
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>学生管理</h2>
|
||||||
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
<Space>
|
||||||
添加学生
|
<Button variant="outline" disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
||||||
</Button>
|
导入名单
|
||||||
|
</Button>
|
||||||
|
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||||
|
添加学生
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -178,6 +366,67 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
header="导入名单"
|
||||||
|
visible={importVisible}
|
||||||
|
onClose={() => setImportVisible(false)}
|
||||||
|
footer={false}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Button loading={importLoading} disabled={!canEdit} onClick={handleImportFromSecRandom}>
|
||||||
|
通过软件“SecRandom”导入(IPC)
|
||||||
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user