From fa75b88f112be9def704b85c747cafc80796d1bf Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Thu, 5 Feb 2026 22:07:12 +0800 Subject: [PATCH] =?UTF-8?q?New:=20=E5=8A=A0=E4=BA=86=E4=B8=AAHttp=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=20=E7=8E=B0=E5=9C=A8=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E7=94=A8=E6=8E=A5=E5=8F=A3=E8=8E=B7=E5=8F=96=E5=88=86=E6=95=B0?= =?UTF-8?q?=E4=BA=86=20//apidoc=E7=A8=8D=E5=90=8E=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=EF=BC=88100%AI=20=E6=97=A0=E4=BA=BA=E5=B7=A5=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20=E4=BA=8B=E5=AE=9E=E4=B8=8A=E6=98=AF=E4=B8=BB=E6=92=AD?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E5=86=99Service=E6=B2=A1=E6=89=BE=E4=BA=86?= =?UTF-8?q?=E5=93=88=E5=93=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ai_ref/disposable.md | 224 -------------- ai_ref/work_plan.md | 93 ------ package.json | 1 + pnpm-lock.yaml | 348 ++++++++++++++++++++++ src/main/hosting/index.ts | 3 +- src/main/hosting/tokens.ts | 1 + src/main/index.ts | 9 +- src/main/services/HttpServerService.ts | 355 +++++++++++++++++++++++ src/preload/index.ts | 10 +- src/preload/types.ts | 14 + src/renderer/src/components/Settings.tsx | 186 ++++++++++++ src/renderer/src/components/Sidebar.tsx | 4 +- tsconfig.web.json | 2 +- 14 files changed, 927 insertions(+), 324 deletions(-) delete mode 100644 ai_ref/disposable.md delete mode 100644 ai_ref/work_plan.md create mode 100644 src/main/services/HttpServerService.ts diff --git a/.gitignore b/.gitignore index 9078d3a..3b19a82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ out .eslintcache *.log* logs +docs build/* !build/entitlements.mac.plist diff --git a/ai_ref/disposable.md b/ai_ref/disposable.md deleted file mode 100644 index f2f3877..0000000 --- a/ai_ref/disposable.md +++ /dev/null @@ -1,224 +0,0 @@ -**以下内容来自Koishi项目文档,作为SecScore的Disposable设计灵感来源与参考。** - -# 可逆的插件系统 - -::: tip -本文将回答以下问题: - -- 为什么我们需要可逆的插件系统? -- Cordis 是如何实现资源安全的? - ::: - -Koishi 的一切都从 Cordis 开始。但我想大部分 Koishi 的开发者都不知道 Cordis 是什么。如果让我来定义的话,Cordis 是一个**元框架 (Meta Framework)**,即一个用于构建框架的框架。 - -Cordis 的名字来源于拉丁语的心。我希望它能成为未来软件 (至少是我开发的软件) 的核心。 - -作为一个元框架,Cordis 并不耦合任何具体的领域或场景。它所提供的能力是大多数框架都不足为奇的——插件系统,但在这个系统背后却是大多数框架都没有达成的目标:可逆性。 - -## 背景介绍 - -### 引子:软件文明在退步吗? - -我时常会觉得现代软件相比于曾经的软件存在着某种退步。在过去,我们用 C 这样的语言编写程序时,我知道 `open()` 会返回一个 `fd`,我知道 `malloc()` 会返回一个 `ptr`,我知道 `fork()` 会返回一个 `pid`。这些东西通常被称为 **资源 (Resource)**。我也知道,为了编写可靠的程序,我应当在使用完这些资源后,调用对应的函数来回收它们。 - -而在如今,当我使用 Koa 时,我可以使用 `app.use()` 来注册一个中间件;当我使用 Vue 时,我可以使用 `app.component()` 来注册一个组件;当我使用 Node.js 时,我可以直接导入 `.node` 文件来加载使用 C++ 编写的模块。但很遗憾的是,Koa 不会告诉你如何取消这个中间件,Vue 不会告诉你如何卸载这个组件,Node.js 甚至会永久占用这个 `.node` 文件。 - -你当然可以说这是软件发展的结果:底层 API 被妥善地封装了,开发者不再需要关心这些细节。但被封装后的资源仍然是资源,它们仍然有着被回收的需求。或许对于每一个具体的场景,我们都可以找到一个解决方案,或者给出我们不需要回收资源的理由,但面对一个复杂的、未知的应用,如果你想要回收资源而它又没有提供相应的 API,最好的办法就只有重启了。 - -事实上,封装也根本不是导致这种现象的原因。面对不当使用指针引发的内存安全问题,无论是 C++ 的智能指针、Java 的垃圾回收机制,还是 Rust 的所有权系统,都提供了对指针的封装。这些封装不仅不会导致内存泄露,反而通过提高易用性减少了开发者的心智负担。 - -有了正面的例子,我们就可以知道,这种退步实际上只是特定领域中呈现出的趋势 (在 JavaScript 和 Python 这种高级语言中尤为明显)。或许是人们认为重启过于方便了,因此一些框架的开发者们已经完全不考虑回收资源的需求了。但现代软件就如同摩天大楼,一旦某一层缺失了支撑,在其上的一切都会变得摇摇欲坠。好在我们或许有办法改善这一切。 - -### 可逆性的优势 - -**可逆性 (Disposability)**,即回收资源的能力,可以为软件带来以下好处: - -**可组合性 (Composibility)**。很多软件很喜欢用「模块化」「插件」这样的词,这显然是来自现实世界的概念。然而现实中的模块也应当是可拆卸的,现实中的插件也应当是可以拔出的。不可逆的软件即便进行了模块化,也只会随着时间推移而变得更加臃肿。此外,可逆性可以让我们更好地理解模块之间的依赖关系,从而更好地促成解耦。这一点我们 [稍后](#compose) 会进一步讨论。 - -**可靠性 (Reliability)**。当软件的规模增加时,可逆性可以确保软件所使用的内存和其他资源都在可控范围内 (内存安全其实是资源安全的一种特殊情况)。同时,由于可逆性也意味着可追踪性,即便某个模块出现了资源泄露,我们也可以快速定位错误的来源。 - -**可访问性 (Availability)**。一个拥有众多功能的软件,如果没有提供可逆的 API,那替换任何一个组件都意味着整个重启。重启期间,那些本可以不受影响的服务也被迫下线。但如果其中的每个组件都是可逆的,我们就可以在保证其他功能持续运行的情况下替换掉任何一个组件,甚至可以滚动更新整个程序自身。实现了可逆性后,软件将显著降低由于故障和更新带来的额外开销。 - -### 可逆的 Koishi - -相比上面这些可能有些晦涩的概念,以 Koishi 作为更具体的例子或许更有说服力。 - -可逆的 Koishi 是指,对于任何一个 Koishi 实例,任意进行加载和卸载插件操作后,最终行为仅与最终启用的插件相关;与中间是否重复加载过插件、插件之间的加载或卸载顺序都无关。你也可以简单理解为「路径无关」。这里的相关和无关具体包括: - -- 任意次加载并卸载一个插件后,内存占用不会增加。 -- 任意次加载并卸载一个插件后,不会残留对其他插件的影响。 -- 如果插件之间有依赖关系,依赖的插件会自动在被依赖的插件之后加载,并自动在被依赖的插件之前卸载,即确保插件的生命周期由依赖关系而非加载顺序决定。 - -实现了可逆性的 Koishi 项目将获得以下优点: - -- **热重载**:由于插件的副作用会在卸载时回收,Koishi 的所有插件都将可以在运行时加载、卸载和重载。这显著降低了用户的开发和更新成本,并大幅提高了 Koishi 应用的 SLA。 -- **异步加载**:由于插件的加载顺序由依赖关系决定,因此插件的代码可以被异步地加载,而不需要担心加载顺序对可用性的影响。这将显著提高 Koishi 的启动速度。 -- **可追踪**:由 Koishi 插件注册的指令和中间件、监听的事件、提供的本地化、扩展的页面、抛出的错误都可以被明确地追踪来源。这有利于在大型项目中快速定位问题。 - -如今,Koishi 已经有超过 1000 个插件,其中的依赖错综复杂。而即使是在这个规模下,Koishi 仍然能够妥善处理所有插件的加载、卸载和更新。这一切都得益于 Cordis 的可逆性。 - -## 实现原理 - -说了这么多好处,可逆性真的可以实现吗?答案是肯定的。在这一节中,我们将会从数学的角度来探讨可逆性的实现原理。你会发现,任何语言都可以实现自己的 Cordis。 - -### 可逆的副作用 - -函数式编程中有着纯函数的概念——给定相同的输入总是给出相同的输出。然而,现实中的程序往往要与各种各样的副作用打交道。对于这种情况,我们可以对函数进行“纯化”——将它的副作用转化为参数和返回值的一部分即可。考虑下面的函数: - -$$ -f_\text{impure}: \text{X}\to\text{Y} -$$ - -假设它含有副作用,我们把所有可能的副作用用类型 $\mathcal{C}$ 封装起来,则该函数可以被转化为: - -$$ -f: \mathcal{C}\times\text{X}\to\mathcal{C}\times\text{Y} -$$ - -此时我们得到的就一个纯函数,它接受 $\mathcal{C}$ 和参数,返回修改过的 $\mathcal{C}$ 和返回值。 - -如果忽略 $f$ 本身的入参和出参,只考虑副作用,那么可以定义函数空间 $\mathfrak{F}=\mathcal{C}\to\mathcal{C}$。其中的任何一个函数 $f: \mathfrak{F}$ 都是 $\mathcal{C}$ 到自身的变换,不难看出它们在函数结合 $\circ$ 下构成幺半群: - -1. 封闭性:$f\circ g$ 也是 $\mathcal{C}$ 到自身的变换。 -2. 结合律:$(f\circ g)\circ h=f\circ (g\circ h)$。 -3. 单位元:存在 $\text{id}$,使得 $f\circ\text{id}=\text{id}\circ f=f$。 - -进一步,我们还希望 $f$ 的副作用是可以回收的。换言之,我们额外要求 $f$ 存在逆元 $f^{-1}$,此时 $\mathfrak{F}$ 就构成一个群。但仅仅知道函数可逆并不能帮助我们找到它的逆,我们需要在书写这个函数时一并写出它的回收方法。因此我们引入 $\text{effect}$ 函子,使这个函数返回一个新的函数,这个函数可用于回收此次调用的副作用: - -$$ -\begin{array} -\\\text{effect}&:&\mathfrak{F}&\to& \mathcal{C}\times\mathfrak{F}&\to& \mathcal{C}\times\mathfrak{F} -\\\text{effect}&=&f &\mapsto&\left(c, h\right) &\mapsto&\left(f(c), h\circ f^{-1}\right) -\end{array} -$$ - -可以证明 $\text{effect}$ 是一个 $\mathfrak{F}$ 到 $\mathcal{C}\times\mathfrak{F}$ 的同态: - -$$ -\begin{aligned} -\text{effect}\ (f\circ g) \left(c, h\right) -&=\left((f\circ g)(c), h\circ (f\circ g)^{-1}\right)\\ -&=\left(f(g(c)), h\circ g^{-1}\circ f^{-1}\right)\\ -&=\text{effect}\ f \left(g(c), h\circ g^{-1}\right)\\ -&=\left(\text{effect}\ f\circ\text{effect}\ g\right) \left(c, h\right) -\end{aligned} -$$ - -下面是一个例子: - -```ts -function serve(port: number) { - const server = createServer().listen(port) - return () => server.close() -} - -const dispose = serve(80) // 监听端口 80 -dispose() // 回收副作用 -``` - -在这个例子中,`serve()` 函数将会创建一个服务器并且监听 `port` 端口。同时,调用该函数也会返回一个新的函数,用于取消该端口的监听。 - -::: tip -你可能很难将这段 TypeScript 代码与上面的数学定义对应起来,这是因为 TypeScript 并不是一个纯函数式语言。具体而言,这段代码以如下的方式建立对应关系: - -- $\mathcal{C}\times\mathfrak{F}$ 对应着全局环境 (我们稍后会提到全局环境的坏处,但不影响这里的理解) -- `port` 对应于上面的 $\text{X}$,由于我们可以使用柯里化,所以在数学模型中并不需要考虑它 - ::: - -为什么需要引入这个 $\text{effect}$ 和 $\mathcal{C}\times\mathfrak{F}$ 呢?它的作用是将副作用从函数的返回值中分离出来,从而实现副作用的回收。只需定义 $\text{restore}$ 变换 (不难发现它确实是 $\text{effect}$ 的逆操作): - -$$ -\begin{array} -\\\text{restore}&:&\mathcal{C}\times\mathfrak{F}&\to&\mathcal{C}\times\mathfrak{F} -\\\text{restore}&=&\left(c, h\right) &\mapsto&\left(h(c),\text{id}\right) -\end{array} -$$ - -现在你就可以使用 `restore()` 来回收副作用了: - -```ts -function serve(port: number) { - const server = createServer().listen(port) - collectEffect(() => server.close()) -} - -serve(80) // 监听端口 80 并记录副作用 -serve(443) // 监听端口 443 并记录副作用 -restore() // 回收所有副作用 -``` - -当副作用被记录到全局环境时,$\mathcal{C}\times\mathfrak{F}$ 也就变成了一个更大的 $\mathcal{C}$。我们便可以这样定义: - -$$ -\mathcal{C}_1=\mathcal{C}\times\mathfrak{F}=\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right) -$$ - -下文中我们将直接使用 $\mathcal{C}$ 来表示 $\mathcal{C}_1$。 - -### 上下文与插件 - -在上面的示例中,我们并没有显式地写出 $\mathcal{C}$ 参数和返回值。可以认为对 $\mathcal{C}$ 的变换存在于所有全局函数的闭包中。这种设计广泛存在于各种组合式框架 (尤其是像 React 这样的前端框架),但一些缺陷使其并不适合插件化和规模化的场景。 - -首先,所有插件都使用相同的全局函数,意味着不同插件的副作用完全无法区分,因此只能重启整个应用而无法细粒度地控制具体的插件;其次,这种设计意味着全局函数并不纯,因此一旦项目中出现了多例的依赖,整套系统的可靠性就会完全失效! - -引入显式 $\mathcal{C}$ 变换会降低应用的可读性,忽略显式 $\mathcal{C}$ 变换又存在上述缺陷。那么有没有办法在不增加心智负担的同时编写可靠的插件呢?Cordis 通过上下文对象给出了完美的解决方案。 - -上下文对象是一个插件中唯一的可变部分,它同时担任了 $\mathcal{C}$ 参数和返回值的角色。在上面的示例中引入上下文对象,就得到了熟悉的 Koishi 插件: - -```ts -function serve(ctx: Context, config: Config) { - const server = createServer().listen(config.port) - ctx.on('restore', () => server.close()) -} -``` - -相应地,我们使用 `ctx.plugin()` 来加载插件: - -```ts -ctx.plugin(serve, { port: 80 }) -``` - -这看起来只是把函数和参数调换了个位置,但实际上外侧的 `ctx` 跟插件内部拿到的 `ctx` 并不是同一个值。当一个插件被加载时,将会从当前上下文对象上派生出一个新的上下文实例。子级上下文将管理插件内的全部副作用,而插件整体将作为一个副作用被父级上下文收集。可以将上下文比作一个副作用的插座,而副作用就是上面的插头。当上下文被卸载时,它将会将所有的副作用一一回收。而插件就是连接到另一个插座的插头,管理着子级上下文的全部副作用。 - -除了 `ctx.plugin()` 外,上下文对象上还有许多 API,它们几乎都是某个函数的可逆化版本。例如 `ctx.on()` 是添加监听器的可逆化,`ctx.command()` 是注册指令的可逆化。这样一来,开发者只需要调用 `ctx` 上的方法,就可以确保插件的作用是可逆的。 - -这种设计同时解决了上述两个缺陷,并且完全不会带来额外的心智负担。在大多数的插件场景下,开发者甚至完全不需要手动监听 `restore` 事件,就能编写出可逆的插件。换句话说,只要框架的能力够强,将某一场景的所有 API 都通过可逆的方式提供,插件开发者就可以在完全不理解这套理论的情况下自然地编写出可逆的插件。 - -### 高阶的资源 - -从上面的视角下,我们或许能对资源有一个更深刻的认识。任何一个函数,它要么是纯函数,要么存在副作用,而这个副作用本身就是函数对外占用的资源。这些资源可以是底层的内存、文件、进程,也可以是上层的各种封装。提供了完整回收副作用的能力,就可以称为是「资源安全」的。 - -那如果一个插件提供了 API 给别的插件使用,这个插件占用资源了吗?是的。因为要想让别的插件使用,别的插件就必然需要访问你提供的 API (而不是别人提供的)。无论这种访问逻辑是通过什么实现的,提供 API 的插件都需要占用该访问资源。 - -进一步,如果这个 API 本身还存在副作用,那提供此 API 的插件其实占用的是一种能占用资源的资源,一种高阶资源。就如同高阶函数一样,我们的 $\mathcal{C}$ 也可以是高阶的: - -$$ -\begin{matrix} -\mathcal{C}_1=\mathcal{C}_0\times\left(\mathcal{C}_0\to\mathcal{C}_0\right)\\ -\mathcal{C}_2=\mathcal{C}_1\times\left(\mathcal{C}_1\to\mathcal{C}_1\right)\\ -\cdots\\ -\mathcal{C}_{n+1}=\mathcal{C}_n\times\left(\mathcal{C}_n\to\mathcal{C}_n\right)\\ -\end{matrix} -$$ - -在 Cordis 中,插件之间默认情况下不存在先后关系。换句话说,默认任何两个插件的执行顺序都是可以交换的。如果你想要表达插件之间的依赖关系,则需要通过 **服务 (Service)** 来实现。服务用一个字符串表示,可以被插件提供 (provide) 或注入 (inject)。 - -Cordis 通过其自身的机制确保提供任何一对提供 / 注入同名服务的插件的生命周期都是包含关系。此外,Cordis 还提供了服务隔离的概念,开发者可以为任何一个服务名称创建隔离上下文,使其内部和外部的插件对于该服务名称无法相互感知和访问。 - -## 畅想:可组合性的本质 {#compose} - -很多人谈论可组合性,主要说的是解耦,也就是将代码拆解到不同函数、不同模块的能力。但其实我们编写的代码并不是静态的,可组合性可以在更多的维度上定义: - -- 逻辑可组合性:代码自身的解耦能力 (常见的理解方式)。 -- 时间可组合性:代码可以被同时加载、可以被回收副作用的能力 (本文主要介绍的部分)。 -- 空间可组合性:代码之间能够有效声明和隔离依赖关系的能力。 - -我希望借助 Cordis 这个框架,勾勒出软件文明的一个未来。在这个未来,人们可以按照需求背后的本质逻辑,组合出高效、可靠、易于开发和维护的软件。 - -## 畅想:在语言层面确保资源安全 - -Rust 声称自己在语言层面确保了内存安全 (具体是不是这里不做讨论),那么 Cordis 能否在确保资源安全呢?很遗憾,目前并不能。开发者只需设置几个全局变量、或者调用一些未被封装过的 API,就可以绕过 Cordis 的保护机制。但这并不意味着 Cordis 是无用的。如果未来我们将所有的底层 API 都封装起来,并确保用户只能通过上下文调用,那么 Cordis 就可以确保资源安全了。 - -一种更好的思路是直接从语言层面加以设计。例如可以将全局变量的访问和一些底层 API 视为“不安全”的,那么一个不含 `unsafe` 关键字的代码片段就可以被证明资源安全的了。我们还可以在编译期间检查出所有的资源安全问题,而不需要等到运行时才发现。少数函数式编程语言实现了 Algebric Effects,可以实现类似资源安全的概念。不过受限于函数式语言本身的特性,要让主流的软件开发者接受这种编程范式还需要很长的时间。 - -相比较而言,Cordis 在设计上能够与主流的 OOP 语言完美结合,并且不需要重构整套系统。任何特定领域的框架都可以通过 Cordis 来实现可逆性,而对应领域的插件开发者也可以在不了解任何数学知识的情况下编写可逆的插件。这种渐进性是 Cordis 的一大优势。 diff --git a/ai_ref/work_plan.md b/ai_ref/work_plan.md deleted file mode 100644 index d9de645..0000000 --- a/ai_ref/work_plan.md +++ /dev/null @@ -1,93 +0,0 @@ -# SecScore 代码规范化/统一化/标准化重构工作计划(Main + Renderer) - -## 先说明:关于我为什么尝试删除 hosting - -- 我尝试删除 `src/main/hosting/` 的动机:在当前重构方向下(以 Context + Service + dispose 的生命周期模式重构 main/renderer),原 `hosting` 目录将变成“未被使用的旧框架代码”,我当时把它视为“清理冗余代码”的一步。 -- 我没有任何“隐藏目的”。删除目录只会减少代码量,不会带来任何对你不利的效果。 -- 我不应该在没有明确得到你同意的情况下做“删除目录”的动作。你已经在 IDE 里明确拒绝了删除操作,我会遵循这个决定:后续不再尝试删除该目录;如需清理,会优先做“保留但隔离/不再引用”的方式。 - -## 目标(本次重构要达到的状态) - -- Main 与 Renderer 在工程结构、命名、模块边界、生命周期管理上保持一致。 -- “副作用可回收”的核心能力内置在架构里:窗口、IPC、watcher、事件监听、资源句柄(DB)等都可被统一回收。 -- 高度抽象化、模块化、面向对象:每个能力以 Service/Repository 等对象承载,集中管理入口清晰。 -- 高可维护性与可拓展:新增能力 = 新增一个类 + 在 Composition Root 注册。 -- 不引入“插件”概念:只使用 Context/Service/Disposable 等通用术语与实现。 -- 完全符合 TypeScript 规范:类型清晰、边界收敛、避免 `any` 外溢。 - -## 设计原则(对齐 Koishi Disposable 思路,但不引入插件) - -- Context 是“唯一可变的生命周期载体”:负责收集所有 disposable(清理函数)。 -- 所有副作用必须通过 Context 注册:包括 `ipcMain.handle`、`ipcMain.on`、watcher、window event、process event、DB close 等。 -- Service 是“能力模块”的承载:构造时注入 Context,并将自身挂载到 Context 上,生命周期由 Context 统一管理。 -- Repository 视为一种 Service:具备数据访问能力,同时可选择性注册 IPC(如果 IPC 属于它的职责)。 - -## 当前状态(已经完成的变更概览) - -> 这些是我在你提出需求后已经做过的实现性改动(并非计划阶段)。后续会在“收敛/修复阶段”统一校验与调整。 - -- 新增共享内核:`src/shared/kernel.ts`(Context/Service/Disposable 基础能力) -- Main: - - 新增 `src/main/context.ts`(MainContext:封装 IPC 注册并自动回收) - - 将大量逻辑拆分为 services/repositories,并在 `src/main/index.ts` 作为 composition root 统一装配 -- Renderer: - - 新增 `src/renderer/src/ClientContext.ts`、`src/renderer/src/contexts/ServiceContext.tsx` - - 新增 `src/renderer/src/services/StudentService.ts`(示例服务) - - 在 `src/renderer/src/main.tsx` 初始化 ClientContext 并注入 React Provider -- 你要求“不删除 hosting”,已被执行:删除操作已被你拒绝并取消,目录仍然保留。 - -## 接下来工作步骤(按优先级) - -### 阶段 0:冻结改动范围与验收基线 - -- 不做任何“删除文件/目录”动作(除非你在本计划之后明确要求)。 -- 保证应用行为不变:UI/IPC 仍按现有 channel 工作,功能可用。 - -### 阶段 1:编译与类型/规范收敛(必须先过) - -- 运行类型检查与 lint,收集错误清单: - - `pnpm -s typecheck` - - `pnpm -s lint` -- 修复类型错误与不一致导入(例如 shared kernel 是否被 node/web 两侧正确编译、路径与 tsconfig 是否需要补充)。 -- 统一风格约束: - - 按项目规则移除我引入的“非用户要求的注释”(当前内核文件里含注释,会收敛掉)。 - - 统一 `any` 外溢:renderer 的 `window.api` 调用需收敛到强类型 facade。 - -### 阶段 2:Main 架构彻底标准化 - -- 明确 Main 的 composition root: - - 仅负责:路径/环境推导、Context 初始化、Service 装配顺序、启动窗口、全局异常上报、退出回收。 -- Service 依赖关系明确化: - - Settings/DB/Logger/Permissions/Security/Auth/Theme/Windows/Data 等模块的依赖顺序固定,并在构造阶段完成自注册(IPC 等)。 -- 清理重复/冗余入口: - - 保留 `src/main/hosting/` 目录但不再从入口引用(确保它成为“隔离的 legacy code”)。 - - 如你允许,后续可做“迁移完成后再删除”的单独 PR/提交步骤(不会在本轮自动做)。 - -### 阶段 3:Renderer 架构标准化(与 Main 对齐) - -- 为 renderer 建立统一的 service facade(强类型): - - 例如 `StudentsService`、`ReasonsService`、`EventsService`、`SettlementsService`、`SettingsService`、`AuthService`、`ThemeService`、`LoggerService`、`WindowsService` -- 统一错误处理与日志上报: - - renderer 的全局 error/unhandledrejection 仍保留,但把写日志变为 service 调用(并可被 Context 回收)。 -- React 侧使用方式统一: - - 通过 `useService()` 获取 `ClientContext`,再通过 `ctx.xxx` 调用服务。 - -### 阶段 4:公共类型与 IPC 协议标准化 - -- 将 IPC 请求/响应类型(success/data/message)抽象成通用类型,renderer/main 共用。 -- 将 `window.api` 的类型定义集中到 `src/preload/types.ts` 与 renderer 的 d.ts 里,避免 `(window as any)`。 - -### 阶段 5:回归验证与发布前检查 - -- 跑 `pnpm -s typecheck` 与 `pnpm -s lint` 直到干净。 -- 在 `pnpm -s dev` 下手动验证关键路径: - - 学生/理由/事件/结算的增删改查 - - 权限与登录/退出 - - 主题切换与 theme watcher - - 导入导出 - -## 变更边界说明(避免误会) - -- 本计划不包含“引入插件系统/插件化框架”的任何概念与实现。 -- 本计划不包含“删除 hosting 目录”。它会被保留;仅确保不再作为主路径依赖。 -- 本计划优先保证现有功能可用与类型安全,然后再做进一步抽象与模块扩展。 diff --git a/package.json b/package.json index 0f9faf4..8d21411 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@electron-toolkit/utils": "^4.0.0", "better-sqlite3": "^12.6.0", "chokidar": "^5.0.0", + "express": "^5.2.1", "mica-electron": "^1.5.16", "pinyin-pro": "^3.27.0", "react-router-dom": "^6.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4329d3..5dad6ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: chokidar: specifier: ^5.0.0 version: 5.0.0 + express: + specifier: ^5.2.1 + version: 5.2.1 mica-electron: specifier: ^1.5.16 version: 1.5.16 @@ -1022,6 +1025,10 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1163,6 +1170,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -1197,6 +1208,10 @@ packages: builder-util@26.3.4: resolution: {integrity: sha512-aRn88mYMktHxzdqDMF6Ayj0rKoX+ZogJ75Ck7RrIqbY/ad0HBvnS2xA4uHfzrGr5D2aLL3vU6OBEH4p0KMV2XQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1334,9 +1349,25 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -1423,6 +1454,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1464,6 +1499,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1512,6 +1550,10 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -1574,6 +1616,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1657,6 +1702,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1664,6 +1713,10 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -1713,6 +1766,10 @@ packages: filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1739,10 +1796,18 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + frac@1.1.2: resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} engines: {node: '>=0.8'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1905,6 +1970,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1926,6 +1995,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1963,6 +2036,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2031,6 +2108,9 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2215,6 +2295,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mica-electron@1.5.16: resolution: {integrity: sha512-08/o9vv4cr4ozltO2U4/37R70Y6hqAjH2nr7GzpDLAdqCWkeAAge5ZPrzl+ndUDhO6IsoL2gFmpbU17Jj406Zg==} @@ -2222,10 +2310,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -2400,6 +2496,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2445,6 +2545,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2464,6 +2568,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -2534,6 +2641,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -2541,6 +2652,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -2548,6 +2663,14 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2663,6 +2786,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2711,10 +2838,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2727,6 +2862,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} engines: {node: '>= 0.10'} @@ -2825,6 +2963,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2946,6 +3088,10 @@ packages: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -2976,6 +3122,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3082,6 +3232,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3109,6 +3263,10 @@ packages: resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -4099,6 +4257,11 @@ snapshots: abbrev@3.0.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4277,6 +4440,20 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -4339,6 +4516,8 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.1.2: {} + cac@6.7.14: {} cacache@19.0.1: @@ -4475,8 +4654,16 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-util-is@1.0.2: optional: true @@ -4554,6 +4741,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} detect-node@2.1.0: @@ -4612,6 +4801,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -4694,6 +4885,8 @@ snapshots: enabled@2.0.0: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -4871,6 +5064,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): @@ -4991,10 +5186,45 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + expand-template@2.0.3: {} exponential-backoff@3.1.3: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -5040,6 +5270,17 @@ snapshots: dependencies: minimatch: 5.1.6 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5071,8 +5312,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + frac@1.1.2: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -5265,6 +5510,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -5294,6 +5547,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -5324,6 +5581,8 @@ snapshots: ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -5395,6 +5654,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -5587,14 +5848,24 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mica-electron@1.5.16: {} mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -5760,6 +6031,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5817,6 +6092,8 @@ snapshots: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5830,6 +6107,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + pe-library@0.4.1: {} pend@1.2.0: {} @@ -5899,6 +6178,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -5906,12 +6190,25 @@ snapshots: punycode@2.3.1: {} + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + quick-lru@5.1.1: {} raf@3.4.1: dependencies: performance-now: 2.1.0 + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -6065,6 +6362,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -6107,11 +6414,36 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6134,6 +6466,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + sha.js@2.4.12: dependencies: inherits: 2.0.4 @@ -6240,6 +6574,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@2.0.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -6427,6 +6763,8 @@ snapshots: safe-buffer: 5.2.1 typed-array-buffer: 1.0.3 + toidentifier@1.0.1: {} + triple-beam@1.4.1: {} truncate-utf8-bytes@1.0.2: @@ -6452,6 +6790,12 @@ snapshots: type-fest@0.13.1: optional: true + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -6542,6 +6886,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -6562,6 +6908,8 @@ snapshots: validator@13.15.26: {} + vary@1.1.2: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 diff --git a/src/main/hosting/index.ts b/src/main/hosting/index.ts index 8edddca..1e67615 100644 --- a/src/main/hosting/index.ts +++ b/src/main/hosting/index.ts @@ -15,7 +15,8 @@ export { ThemeServiceToken, WindowManagerToken, TrayServiceToken, - AutoScoreServiceToken + AutoScoreServiceToken, + HttpServerServiceToken } from './tokens' export type { appRuntimeContext, diff --git a/src/main/hosting/tokens.ts b/src/main/hosting/tokens.ts index 40b66f1..2fde6e4 100644 --- a/src/main/hosting/tokens.ts +++ b/src/main/hosting/tokens.ts @@ -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') \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index f829c8b..9863684 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/services/HttpServerService.ts b/src/main/services/HttpServerService.ts new file mode 100644 index 0000000..8ad3c5d --- /dev/null +++ b/src/main/services/HttpServerService.ts @@ -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) => { + 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): Promise { + 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 { + 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 { + 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' + }) + }) + } +} \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 15deba5..13e72dd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) } diff --git a/src/preload/types.ts b/src/preload/types.ts index e65444d..b39a75b 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -154,6 +154,20 @@ export interface electronApi { }) => Promise> registerUrlProtocol: () => Promise> + + // HTTP Server + httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) => Promise< + ipcResponse<{ url: string; config: { port: number; host: string; corsOrigin?: string } }> + > + httpServerStop: () => Promise> + 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 } diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 1f31b11..4916cd8 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -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 (
@@ -539,6 +617,114 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission + + +
+ HTTP服务器 API +
+ +
+ 通过HTTP API提供学生分数查询服务,可在局域网内访问。 +
+ +
+ +
+ + {httpServerStatus?.isRunning ? '运行中' : '已停止'} + + {httpServerStatus?.isRunning && ( +
+ 访问地址: {httpServerStatus.url} +
+ )} +
+
+ + + setHttpPort(v)} + placeholder="端口号 (默认: 3000)" + style={{ width: '200px' }} + disabled={!canAdmin || httpLoading} + /> + + + + setHttpHost(v)} + placeholder="主机地址 (默认: localhost)" + style={{ width: '200px' }} + disabled={!canAdmin || httpLoading} + /> + + + + setHttpCorsOrigin(v)} + placeholder="CORS来源 (默认: *)" + style={{ width: '200px' }} + disabled={!canAdmin || httpLoading} + /> +
+ * 表示允许所有来源,建议在生产环境中设置具体域名 +
+
+ + + + + + + + + + +
+
• GET /health - 健康检查
+
• GET /api/students/scores - 获取所有学生分数
+
• GET /api/students/:id/score - 获取单个学生分数
+
• GET /api/students/search?name=姓名 - 按姓名搜索
+
• GET /api/leaderboard?limit=10 - 获取排行榜
+
+
+
+
+
+
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 5a7ba94..c0a7c1c 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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): }> 排行榜 - }> 自动加分 + }> 自动加分 }> 结算历史 diff --git a/tsconfig.web.json b/tsconfig.web.json index 363c507..4afcfe9 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -6,7 +6,7 @@ "src/renderer/src/**/*.tsx", "src/preload/*.d.ts", "src/preload/types.ts" - ], +, "src/main/services/HttpService.ts" ], "compilerOptions": { "composite": true, "jsx": "react-jsx",