New: 加了个Http服务器 现在可以用接口获取分数了 //apidoc稍后补上(100%AI 无人工添加 事实上是主播不会写Service没找了哈哈)

This commit is contained in:
NanGua-QWQ
2026-02-05 22:07:12 +08:00
parent 08d58bbf11
commit fa75b88f11
14 changed files with 927 additions and 324 deletions
+2 -1
View File
@@ -15,7 +15,8 @@ export {
ThemeServiceToken,
WindowManagerToken,
TrayServiceToken,
AutoScoreServiceToken
AutoScoreServiceToken,
HttpServerServiceToken
} from './tokens'
export type {
appRuntimeContext,
+1
View File
@@ -14,3 +14,4 @@ export const ThemeServiceToken = Symbol.for('secscore.themeService')
export const WindowManagerToken = Symbol.for('secscore.windowManager')
export const TrayServiceToken = Symbol.for('secscore.trayService')
export const AutoScoreServiceToken = Symbol.for('secscore.autoScoreService')
export const HttpServerServiceToken = Symbol.for('secscore.httpServerService')
+8 -1
View File
@@ -35,8 +35,10 @@ import {
ThemeServiceToken,
WindowManagerToken,
TrayServiceToken,
AutoScoreServiceToken
AutoScoreServiceToken,
HttpServerServiceToken
} from './hosting'
import { HttpServerService } from './services/HttpServerService'
type mainAppConfig = {
isDev: boolean
@@ -257,6 +259,10 @@ app.whenReady().then(async () => {
AutoScoreServiceToken,
(p) => new AutoScoreService(p.get(MainContext))
)
services.addSingleton(
HttpServerServiceToken,
(p) => new HttpServerService(p.get(MainContext))
)
})
.configure(async (_builderContext, appCtx) => {
const services = appCtx.services
@@ -279,6 +285,7 @@ app.whenReady().then(async () => {
tray.initialize()
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
autoScore.initialize?.()
services.get(HttpServerServiceToken)
})
.configure(async (_builderContext, appCtx) => {
const services = appCtx.services
+355
View File
@@ -0,0 +1,355 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import express, { type Express, type Request, type Response, type NextFunction } from 'express'
import { StudentEntity } from '../db/entities'
import net from 'net'
declare module '../../shared/kernel' {
interface Context {
httpServer: HttpServerService
}
}
export interface HttpServerConfig {
port: number
host: string
corsOrigin?: string
}
export class HttpServerService extends Service {
private app: Express | null = null
private server: any = null
private config: HttpServerConfig = {
port: 3000,
host: 'localhost',
corsOrigin: '*' // 默认允许所有跨域请求
}
constructor(ctx: MainContext) {
super(ctx, 'httpServer')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private get permissions() {
return this.ctx.permissions
}
private registerIpc() {
// 启动HTTP服务器
this.mainCtx.handle('http:server:start', async (event, config?: Partial<HttpServerConfig>) => {
if (!this.permissions.requirePermission(event, 'admin')) {
return { success: false, message: 'Permission denied' }
}
try {
await this.start(config)
return {
success: true,
data: {
url: `http://${this.config.host}:${this.config.port}`,
config: this.config
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, message: `Failed to start HTTP server: ${message}` }
}
})
// 停止HTTP服务器
this.mainCtx.handle('http:server:stop', async (event) => {
if (!this.permissions.requirePermission(event, 'admin')) {
return { success: false, message: 'Permission denied' }
}
try {
await this.stop()
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, message: `Failed to stop HTTP server: ${message}` }
}
})
// 获取HTTP服务器状态
this.mainCtx.handle('http:server:status', async (event) => {
if (!this.permissions.requirePermission(event, 'admin')) {
return { success: false, message: 'Permission denied' }
}
return {
success: true,
data: {
isRunning: this.isRunning(),
config: this.config,
url: this.isRunning() ? `http://${this.config.host}:${this.config.port}` : null
}
}
})
}
async start(config?: Partial<HttpServerConfig>): Promise<void> {
if (this.isRunning()) {
throw new Error('HTTP server is already running')
}
// 合并配置
this.config = { ...this.config, ...config }
// 检查端口是否可用,如果不可用则自动寻找可用端口
const originalPort = this.config.port
let attempts = 0
const maxAttempts = 100 // 最多尝试100个端口
while (attempts < maxAttempts) {
if (await this.isPortAvailable(this.config.port)) {
break
}
// 端口被占用,尝试下一个端口
this.config.port++
attempts++
}
if (attempts === maxAttempts) {
throw new Error(`Could not find available port after ${maxAttempts} attempts starting from ${originalPort}`)
}
if (attempts > 0) {
this.mainCtx.logger.warn(
`Original port ${originalPort} was unavailable. Using port ${this.config.port} instead.`
)
}
// 创建Express应用
this.app = express()
// 配置中间件
this.app.use(express.json())
this.app.use(express.urlencoded({ extended: true }))
// 简单的CORS处理
this.app.use((_: Request, res: Response, next: NextFunction) => {
res.header('Access-Control-Allow-Origin', this.config.corsOrigin || '*')
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
next()
})
// 设置路由
this.setupRoutes()
// 启动服务器
return new Promise((resolve, reject) => {
this.server = this.app!.listen(this.config.port, this.config.host, () => {
this.mainCtx.logger.info(
`HTTP server started at http://${this.config.host}:${this.config.port}`
)
resolve()
})
this.server.on('error', (error: Error) => {
this.mainCtx.logger.error(`HTTP server error: ${error.message}`)
reject(error)
})
})
}
async stop(): Promise<void> {
if (!this.isRunning()) {
return
}
return new Promise((resolve, reject) => {
this.server!.close((error?: Error) => {
if (error) {
this.mainCtx.logger.error(`Failed to stop HTTP server: ${error.message}`)
reject(error)
} else {
this.mainCtx.logger.info('HTTP server stopped')
this.server = null
this.app = null
resolve()
}
})
})
}
isRunning(): boolean {
return this.server !== null && this.app !== null
}
private async isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.listen(port, '127.0.0.1')
server.on('error', () => {
resolve(false)
})
server.on('listening', () => {
server.close(() => {
resolve(true)
})
})
})
}
private setupRoutes() {
if (!this.app) return
// 健康检查端点
this.app.get('/health', (_, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
// 获取所有学生分数
this.app.get('/api/students/scores', async (_req: Request, res: Response) => {
try {
const studentRepo = this.mainCtx.db.dataSource.getRepository(StudentEntity)
const students = await studentRepo.find({
select: ['id', 'name', 'score'],
order: { score: 'DESC', name: 'ASC' }
})
res.json({
success: true,
data: students.map((student) => ({
id: student.id,
name: student.name,
score: student.score
}))
})
} catch (error) {
this.mainCtx.logger.error(`Failed to fetch student scores: ${error}`)
res.status(500).json({
success: false,
message: 'Failed to fetch student scores'
})
}
})
// 获取单个学生分数
this.app.get('/api/students/:id/score', async (_req: Request, res: Response) => {
try {
const id = parseInt(_req.params.id)
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: 'Invalid student ID'
})
}
const studentRepo = this.mainCtx.db.dataSource.getRepository(StudentEntity)
const student = await studentRepo.findOne({
where: { id },
select: ['id', 'name', 'score']
})
if (!student) {
return res.status(404).json({
success: false,
message: 'Student not found'
})
}
res.json({
success: true,
data: {
id: student.id,
name: student.name,
score: student.score
}
})
} catch (error) {
this.mainCtx.logger.error(`Failed to fetch student score: ${error}`)
res.status(500).json({
success: false,
message: 'Failed to fetch student score'
})
}
})
// 按姓名搜索学生分数
this.app.get('/api/students/search', async (req: Request, res: Response) => {
try {
const name = req.query.name as string
if (!name || name.trim() === '') {
return res.status(400).json({
success: false,
message: 'Name parameter is required'
})
}
const studentRepo = this.mainCtx.db.dataSource.getRepository(StudentEntity)
const students = await studentRepo.find({
where: { name },
select: ['id', 'name', 'score']
})
res.json({
success: true,
data: students.map((student) => ({
id: student.id,
name: student.name,
score: student.score
}))
})
} catch (error) {
this.mainCtx.logger.error(`Failed to search student: ${error}`)
res.status(500).json({
success: false,
message: 'Failed to search student'
})
}
})
// 获取排行榜(前N名学生)
this.app.get('/api/leaderboard', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10
const studentRepo = this.mainCtx.db.dataSource.getRepository(StudentEntity)
const students = await studentRepo.find({
select: ['id', 'name', 'score'],
order: { score: 'DESC', name: 'ASC' },
take: limit
})
res.json({
success: true,
data: students.map((student) => ({
id: student.id,
name: student.name,
score: student.score
}))
})
} catch (error) {
this.mainCtx.logger.error(`Failed to fetch leaderboard: ${error}`)
res.status(500).json({
success: false,
message: 'Failed to fetch leaderboard'
})
}
})
// 404处理
this.app.use((_, res: Response) => {
res.status(404).json({
success: false,
message: 'Endpoint not found'
})
})
// 错误处理中间件
this.app.use((error: Error, _: Request, res: Response, __: NextFunction) => {
this.mainCtx.logger.error(`HTTP server error: ${error.message}`)
res.status(500).json({
success: false,
message: 'Internal server error'
})
})
}
}
+8 -2
View File
@@ -103,8 +103,14 @@ const api = {
writeLog: (payload: { level: string; message: string; meta?: any }) =>
ipcRenderer.invoke('log:write', payload),
registerUrlProtocol: () => ipcRenderer.invoke('app:register-url-protocol')
,
registerUrlProtocol: () => ipcRenderer.invoke('app:register-url-protocol'),
// HTTP Server
httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) =>
ipcRenderer.invoke('http:server:start', config),
httpServerStop: () => ipcRenderer.invoke('http:server:stop'),
httpServerStatus: () => ipcRenderer.invoke('http:server:status'),
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args)
}
+14
View File
@@ -154,6 +154,20 @@ export interface electronApi {
}) => Promise<ipcResponse<void>>
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>>
// HTTP Server
httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) => Promise<
ipcResponse<{ url: string; config: { port: number; host: string; corsOrigin?: string } }>
>
httpServerStop: () => Promise<ipcResponse<void>>
httpServerStatus: () => Promise<
ipcResponse<{
isRunning: boolean
config: { port: number; host: string; corsOrigin?: string }
url: string | null
}>
>
// Generic invoke wrapper (minimal compatibility API)
invoke?: (channel: string, ...args: any[]) => Promise<any>
}
+186
View File
@@ -62,6 +62,17 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const [settleDialogVisible, setSettleDialogVisible] = useState(false)
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
// HTTP Server状态
const [httpServerStatus, setHttpServerStatus] = useState<{
isRunning: boolean
config: { port: number; host: string; corsOrigin?: string }
url: string | null
} | null>(null)
const [httpLoading, setHttpLoading] = useState(false)
const [httpPort, setHttpPort] = useState('3000')
const [httpHost, setHttpHost] = useState('localhost')
const [httpCorsOrigin, setHttpCorsOrigin] = useState('*')
const canAdmin = permission === 'admin'
@@ -92,6 +103,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
useEffect(() => {
loadAll()
refreshHttpServerStatus()
if (!(window as any).api) return
const unsubscribe = (window as any).api.onSettingChanged((change: any) => {
setSettings((prev) => {
@@ -247,6 +259,72 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return
setSettleDialogVisible(true)
}
// HTTP Server函数
const refreshHttpServerStatus = async () => {
if (!(window as any).api) return
try {
const res = await (window as any).api.httpServerStatus()
if (res.success && res.data) {
setHttpServerStatus(res.data)
setHttpPort(res.data.config.port.toString())
setHttpHost(res.data.config.host)
setHttpCorsOrigin(res.data.config.corsOrigin || '*')
}
} catch (error) {
console.error('Failed to refresh HTTP server status:', error)
}
}
const handleHttpServerStart = async () => {
if (!(window as any).api) return
setHttpLoading(true)
try {
const config: any = {}
const portNum = parseInt(httpPort)
if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) {
config.port = portNum
}
if (httpHost.trim()) {
config.host = httpHost.trim()
}
if (httpCorsOrigin.trim()) {
config.corsOrigin = httpCorsOrigin.trim()
}
const res = await (window as any).api.httpServerStart(config)
if (res.success) {
MessagePlugin.success(`HTTP服务器已启动: ${res.data.url}`)
await refreshHttpServerStatus()
} else {
MessagePlugin.error(res.message || '启动失败')
}
} catch (error) {
MessagePlugin.error('启动HTTP服务器时发生错误')
console.error(error)
} finally {
setHttpLoading(false)
}
}
const handleHttpServerStop = async () => {
if (!(window as any).api) return
setHttpLoading(true)
try {
const res = await (window as any).api.httpServerStop()
if (res.success) {
MessagePlugin.success('HTTP服务器已停止')
await refreshHttpServerStatus()
} else {
MessagePlugin.error(res.message || '停止失败')
}
} catch (error) {
MessagePlugin.error('停止HTTP服务器时发生错误')
console.error(error)
} finally {
setHttpLoading(false)
}
}
return (
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
@@ -539,6 +617,114 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="http" label="HTTP服务器">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
HTTP服务器 API
</div>
<Divider />
<div
style={{
marginBottom: '12px',
fontSize: '13px',
color: 'var(--ss-text-secondary)'
}}
>
HTTP API提供学生分数查询服务访
</div>
<Form labelWidth={120}>
<Form.FormItem label="服务器状态">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Tag
theme={httpServerStatus?.isRunning ? 'success' : 'default'}
variant="light"
>
{httpServerStatus?.isRunning ? '运行中' : '已停止'}
</Tag>
{httpServerStatus?.isRunning && (
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
访: {httpServerStatus.url}
</div>
)}
</div>
</Form.FormItem>
<Form.FormItem label="端口设置">
<Input
value={httpPort}
onChange={(v) => setHttpPort(v)}
placeholder="端口号 (默认: 3000)"
style={{ width: '200px' }}
disabled={!canAdmin || httpLoading}
/>
</Form.FormItem>
<Form.FormItem label="主机地址">
<Input
value={httpHost}
onChange={(v) => setHttpHost(v)}
placeholder="主机地址 (默认: localhost)"
style={{ width: '200px' }}
disabled={!canAdmin || httpLoading}
/>
</Form.FormItem>
<Form.FormItem label="CORS来源">
<Input
value={httpCorsOrigin}
onChange={(v) => setHttpCorsOrigin(v)}
placeholder="CORS来源 (默认: *)"
style={{ width: '200px' }}
disabled={!canAdmin || httpLoading}
/>
<div style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
*
</div>
</Form.FormItem>
<Form.FormItem label="操作">
<Space>
<Button
theme="primary"
loading={httpLoading}
disabled={!canAdmin}
onClick={handleHttpServerStart}
>
{httpServerStatus?.isRunning ? '重启服务器' : '启动服务器'}
</Button>
<Button
theme="danger"
variant="outline"
loading={httpLoading}
disabled={!canAdmin || !httpServerStatus?.isRunning}
onClick={handleHttpServerStop}
>
</Button>
<Button
variant="outline"
disabled={!canAdmin || httpLoading}
onClick={refreshHttpServerStatus}
>
</Button>
</Space>
</Form.FormItem>
<Form.FormItem label="API端点">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
<div> GET /health - </div>
<div> GET /api/students/scores - </div>
<div> GET /api/students/:id/score - </div>
<div> GET /api/students/search?name= - </div>
<div> GET /api/leaderboard?limit=10 - </div>
</div>
</Form.FormItem>
</Form>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="url" label="URL 链接">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
+2 -2
View File
@@ -6,7 +6,7 @@ import {
RootListIcon,
ViewListIcon,
HomeIcon,
MoneyIcon
ReplayIcon
} from 'tdesign-icons-react'
import appLogo from '../assets/logoHD.svg'
@@ -78,7 +78,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="auto-score" icon={<MoneyIcon />}>
<Menu.MenuItem value="auto-score" icon={<ReplayIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>