mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
Merge branch 'main' of https://github.com/Linkon-lcw/SecScore
This commit is contained in:
@@ -1,34 +1,125 @@
|
|||||||
# secscore
|
# SecScore
|
||||||
|
|
||||||
An Electron application with React and TypeScript
|
一款本地离线(未来加入远程模式)的“教育积分管理”桌面软件(Electron + React + TypeScript),用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供基础的权限保护与数据备份。
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## 主要功能
|
||||||
|
|
||||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
- 学生管理
|
||||||
|
- 添加/删除学生
|
||||||
|
- 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)
|
||||||
|
- 积分管理
|
||||||
|
- 选择学生并提交加分/扣分
|
||||||
|
- 支持“预设理由”一键填充理由与分值
|
||||||
|
- 支持撤销最近的积分记录(撤销后学生积分会回滚)
|
||||||
|
- 理由管理
|
||||||
|
- 维护“预设理由”(分类、理由内容、预设分值)
|
||||||
|
- 排行榜
|
||||||
|
- 支持按“今天 / 本周 / 本月”查看积分变化
|
||||||
|
- 支持导出排行榜为 XLSX
|
||||||
|
- 支持查看单个学生的操作记录(文本列表)
|
||||||
|
- 结算与历史
|
||||||
|
- “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零
|
||||||
|
- 在“结算历史”查看每个阶段的排行榜
|
||||||
|
- 系统设置
|
||||||
|
- 主题切换
|
||||||
|
- 日志查看/导出/清空
|
||||||
|
- 数据导入/导出(JSON)
|
||||||
|
- 密码保护(管理密码 / 积分密码)与找回字符串
|
||||||
|
|
||||||
## Project Setup
|
## 使用方法
|
||||||
|
|
||||||
### Install
|
### 1. 权限与解锁
|
||||||
|
|
||||||
|
- 右上角会显示当前权限:管理权限 / 积分权限 / 只读
|
||||||
|
- 若设置了密码,可通过右上角“输入密码”进行解锁
|
||||||
|
- 管理密码:全功能(学生管理、理由管理、结算、数据管理等)
|
||||||
|
- 积分密码:仅允许积分相关操作
|
||||||
|
- 点击“锁定”可切回只读状态
|
||||||
|
- 无密码时默认视为管理权限
|
||||||
|
|
||||||
|
### 2. 学生管理(导入名单)
|
||||||
|
|
||||||
|
入口:左侧菜单 → 学生管理 → 导入名单
|
||||||
|
|
||||||
|
- 通过 xlsx 导入
|
||||||
|
1. 选择一个 `.xlsx` 文件(默认读取第一个工作表)
|
||||||
|
2. 在预览表格里点击表头,选择“姓名列”
|
||||||
|
3. 点击“导入”
|
||||||
|
4. 导入完成后会提示“新增 / 跳过”数量
|
||||||
|
|
||||||
|
建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。
|
||||||
|
|
||||||
|
### 3. 积分管理(加分/扣分)
|
||||||
|
|
||||||
|
入口:左侧菜单 → 积分管理
|
||||||
|
|
||||||
|
1. 在“姓名”选择一个学生(支持搜索)
|
||||||
|
2. 选择“加分/扣分”,并输入分值
|
||||||
|
3. 在“理由内容”填写原因(可手动输入)
|
||||||
|
4. 点击“确认提交”
|
||||||
|
|
||||||
|
快捷理由:
|
||||||
|
- 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)
|
||||||
|
|
||||||
|
撤销最近记录:
|
||||||
|
- “最近记录”默认折叠,展开后可对记录点击“撤销”
|
||||||
|
- 撤销会回滚该条记录对学生积分的影响
|
||||||
|
|
||||||
|
### 4. 排行榜与导出
|
||||||
|
|
||||||
|
入口:左侧菜单 → 排行榜
|
||||||
|
|
||||||
|
- 可切换统计范围:今天 / 本周 / 本月
|
||||||
|
- 点击“导出 XLSX”可导出当前排行榜
|
||||||
|
- 点击某个学生的“查看”可打开该学生的操作记录
|
||||||
|
|
||||||
|
### 5. 结算与数据备份
|
||||||
|
|
||||||
|
入口:左侧菜单 → 系统设置 → 数据管理
|
||||||
|
|
||||||
|
- 结算并重新开始
|
||||||
|
- 会把当前未结算的积分记录归档为一个阶段
|
||||||
|
- 会将所有学生当前积分清零
|
||||||
|
- 学生名单不变;结算后的历史在“结算历史”查看
|
||||||
|
- 导出 JSON(强烈建议定期备份)
|
||||||
|
- 导入会覆盖现有学生/理由/积分记录/设置
|
||||||
|
- 安全相关设置(密码等)不会随导入写入
|
||||||
|
|
||||||
|
## 开发与运行(面向贡献者)
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js(建议使用 LTS 版本)
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### 开发模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
### 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For windows
|
pnpm build
|
||||||
$ pnpm build:win
|
```
|
||||||
|
|
||||||
# For macOS
|
(可选)打包:
|
||||||
$ pnpm build:mac
|
|
||||||
|
```bash
|
||||||
# For Linux
|
pnpm build:win
|
||||||
$ pnpm build:linux
|
pnpm build:unpack
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm typecheck
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -49,65 +48,12 @@ export class StudentRepository extends Service {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
this.mainCtx.handle('db:student:importFromSecRandom', async (event, input: any) => {
|
this.mainCtx.handle('db:student:importFromSecRandom', async (event) => {
|
||||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||||
return { success: false, message: 'Permission denied' }
|
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 {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
data: {
|
message: 'SecRandom IPC 导入已禁用(后续再做)'
|
||||||
className: roster.className,
|
|
||||||
sourceMessage: roster.message,
|
|
||||||
...result,
|
|
||||||
raw: resp
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,151 +156,3 @@ export class StudentRepository extends Service {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,8 +18,6 @@ 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[] }) =>
|
importStudentsFromXlsx: (params: { names: string[] }) =>
|
||||||
ipcRenderer.invoke('db:student:importFromXlsx', params),
|
ipcRenderer.invoke('db:student:importFromXlsx', params),
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
MessagePlugin,
|
MessagePlugin,
|
||||||
Card,
|
Card,
|
||||||
|
Collapse,
|
||||||
Table,
|
Table,
|
||||||
PrimaryTableCol,
|
PrimaryTableCol,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -318,16 +319,20 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="最近记录" style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||||
<Table
|
<Collapse defaultValue={[]} expandMutex>
|
||||||
data={events}
|
<Collapse.Panel header="最近记录" value="recent">
|
||||||
columns={columns}
|
<Table
|
||||||
rowKey="uuid"
|
data={events}
|
||||||
loading={loading}
|
columns={columns}
|
||||||
size="small"
|
rowKey="uuid"
|
||||||
pagination={{ pageSize: 5, total: events.length }}
|
loading={loading}
|
||||||
style={{ color: 'var(--ss-text-main)' }}
|
size="small"
|
||||||
/>
|
pagination={{ pageSize: 5, total: events.length }}
|
||||||
|
style={{ color: 'var(--ss-text-main)' }}
|
||||||
|
/>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
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 [importVisible, setImportVisible] = useState(false)
|
||||||
const [importLoading, setImportLoading] = useState(false)
|
|
||||||
const [xlsxVisible, setXlsxVisible] = useState(false)
|
const [xlsxVisible, setXlsxVisible] = useState(false)
|
||||||
const [xlsxLoading, setXlsxLoading] = useState(false)
|
const [xlsxLoading, setXlsxLoading] = useState(false)
|
||||||
const [xlsxFileName, setXlsxFileName] = useState('')
|
const [xlsxFileName, setXlsxFileName] = useState('')
|
||||||
@@ -112,40 +111,6 @@ 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) => {
|
const excelColName = (idx: number) => {
|
||||||
let n = idx + 1
|
let n = idx + 1
|
||||||
let s = ''
|
let s = ''
|
||||||
@@ -375,9 +340,6 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Button loading={importLoading} disabled={!canEdit} onClick={handleImportFromSecRandom}>
|
|
||||||
通过软件“SecRandom”导入(IPC)
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
loading={xlsxLoading}
|
loading={xlsxLoading}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
|||||||
Reference in New Issue
Block a user