Revert "feat: 实现依赖注入系统、插件架构和窗口管理"

This reverts commit 18eada8ff3.
This commit is contained in:
Yukino_fox
2026-05-02 14:08:44 +08:00
parent 8c7aaadabc
commit 3e85c32c9c
47 changed files with 180 additions and 5347 deletions
-184
View File
@@ -1,184 +0,0 @@
import type {
ConfigureHostDelegate,
HostBuilderContext,
HostExposure,
HostedService,
PluginHostApplicationContext,
PluginMiddleware,
ServiceToken,
} from "./types"
import { ServiceProvider } from "./serviceCollection"
// 插件Host应用类,管理应用的生命周期和托管服务
export class PluginHostApplication {
private hostedInstances: HostedService[] = [] // 已启动的托管服务实例
private hostDisposers: Array<() => void> = [] // 清理函数列表
private started = false // 是否已启动
constructor(
private readonly context: HostBuilderContext, // Host构建器上下文
private readonly provider: ServiceProvider, // 服务提供者
private readonly configureDelegates: ConfigureHostDelegate[], // 配置委托
private readonly middleware: PluginMiddleware[], // 中间件
private readonly hostedTokens: ServiceToken<HostedService>[] // 托管服务令牌
) {}
// 包装应用,添加服务暴露功能
withExposures(exposures: HostExposure[]): PluginHostApplicationWithExposures {
return new PluginHostApplicationWithExposures(this, exposures)
}
// 启动应用
async start(): Promise<void> {
if (this.started) return
const appCtx = this.createApplicationContext() // 创建应用上下文
// 执行所有配置委托
for (const configure of this.configureDelegates) {
await configure(this.context, appCtx)
}
// 通过中间件管道执行终端逻辑(启动托管服务)
await this.dispatch(0, appCtx, async () => {
await this.bootstrapHostedServices()
})
this.started = true
await this.context.lifetime.notifyStarted() // 通知生命周期已启动
}
// 停止应用
async stop(): Promise<void> {
if (!this.started) return
await this.context.lifetime.notifyStopping() // 通知正在停止
await this.disposeHostedServices() // 停止托管服务
await this.context.lifetime.notifyStopped() // 通知已停止
this.started = false
}
// 释放资源
async dispose(): Promise<void> {
await this.stop()
await this.provider.dispose() // 释放服务提供者
}
// 获取服务提供者
get services(): ServiceProvider {
return this.provider
}
// 获取Host上下文
get hostContext(): HostBuilderContext {
return this.context
}
// 创建应用上下文
private createApplicationContext(): PluginHostApplicationContext {
return {
ctx: this.context.ctx,
services: this.provider,
host: this.context,
}
}
// 启动所有托管服务
private async bootstrapHostedServices() {
for (const token of this.hostedTokens) {
const service = this.provider.get(token) // 从提供者获取服务实例
this.hostedInstances.push(service)
if (typeof service.start === "function") {
await service.start() // 调用启动方法
}
}
}
// 停止所有托管服务
private async disposeHostedServices() {
while (this.hostedInstances.length) {
const service = this.hostedInstances.pop()
if (!service) continue
if (typeof service.stop === "function") {
await service.stop() // 调用停止方法
}
}
}
// 执行中间件管道
private async dispatch(
index: number,
appCtx: PluginHostApplicationContext,
terminal: () => Promise<void>
) {
const middleware = this.middleware[index]
if (!middleware) {
await terminal() // 执行终端逻辑
return
}
// 调用中间件,传入下一个中间件的执行函数
await middleware(appCtx, () => this.dispatch(index + 1, appCtx, terminal))
}
}
// 带服务暴露的Host应用类
export class PluginHostApplicationWithExposures {
private readonly exposureDisposers: Array<() => void> = [] // 暴露服务的清理函数
constructor(
private readonly app: PluginHostApplication,
private readonly exposures: HostExposure[]
) {}
// 启动应用,包括注册暴露服务
async start(): Promise<void> {
try {
await this.registerExposures() // 先注册暴露服务
await this.app.start() // 再启动应用
} catch (error) {
await this.disposeExposures() // 出错时清理暴露服务
throw error
}
}
// 停止应用,包括清理暴露服务
async stop(): Promise<void> {
await this.app.stop()
await this.disposeExposures()
}
// 释放资源
async dispose(): Promise<void> {
await this.app.dispose()
await this.disposeExposures()
}
// 获取服务提供者
get services() {
return this.app.services
}
// 获取Host上下文
get hostContext() {
return this.app.hostContext
}
// 注册暴露的服务到插件上下文
private async registerExposures() {
const ctx = this.app.hostContext.ctx
if (!ctx.services) return
for (const exposure of this.exposures) {
const value = exposure.resolver(this.app.services) // 解析服务值
const disposer = ctx.services.provide(exposure.name, value) // 注册到服务API
this.exposureDisposers.push(disposer)
}
}
// 清理暴露的服务
private async disposeExposures() {
while (this.exposureDisposers.length) {
const dispose = this.exposureDisposers.pop()
if (!dispose) continue
await dispose() // 调用清理函数
}
}
}
-167
View File
@@ -1,167 +0,0 @@
import { ServiceCollection, ServiceProvider } from "./serviceCollection"
import { PluginHostApplication, PluginHostApplicationWithExposures } from "./hostApplication"
import {
type ConfigureHostDelegate,
type ConfigureServicesDelegate,
type HostBuilderContext,
type HostBuilderSettings,
type HostExposure,
type HostExposureResolver,
type HostedService,
type PluginMiddleware,
type PluginRuntimeContext,
type ServiceToken,
} from "./types"
// ExamAware Host构建器,类似.NET的HostBuilder,用于配置插件的服务和生命周期
export class ExamAwareHostBuilder {
private readonly serviceCollection: ServiceCollection // 服务集合,管理所有注册的服务
private readonly configureServicesDelegates: ConfigureServicesDelegate[] = [] // 配置服务的委托列表
private readonly configureDelegates: ConfigureHostDelegate[] = [] // 配置应用的委托列表
private readonly middleware: PluginMiddleware[] = [] // 中间件列表
private readonly hostedServices: ServiceToken<HostedService>[] = [] // 托管服务的令牌列表
private readonly exposures: HostExposure[] = [] // 要暴露的服务列表
private readonly builderContext: HostBuilderContext // 构建器上下文
constructor(
private readonly runtimeCtx: PluginRuntimeContext,
settings: HostBuilderSettings = {}
) {
this.serviceCollection = new ServiceCollection(runtimeCtx)
this.builderContext = {
ctx: runtimeCtx,
environmentName: settings.environment ?? process.env.EXAMAWARE_ENV ?? "Production",
properties: new Map(Object.entries(settings.properties ?? {})),
lifetime: this.serviceCollection.getLifetime(), // 获取应用生命周期管理器
}
}
// 获取构建器上下文
get context(): HostBuilderContext {
return this.builderContext
}
// 添加服务配置委托
configureServices(callback: ConfigureServicesDelegate): this {
this.configureServicesDelegates.push(callback)
return this
}
// 添加应用配置委托
configure(callback: ConfigureHostDelegate): this {
this.configureDelegates.push(callback)
return this
}
// 添加中间件
use(middleware: PluginMiddleware): this {
this.middleware.push(middleware)
return this
}
// 添加托管服务
addHostedService(token: ServiceToken<HostedService>): this {
this.hostedServices.push(token)
return this
}
// 暴露服务到插件上下文
exposeHostService(name: string, resolver: HostExposureResolver): this {
const normalized = this.normalizeResolver(resolver)
if (normalized) {
this.exposures.push({ name, resolver: normalized })
}
return this
}
// 构建Host应用
async build(): Promise<PluginHost> {
// 执行所有服务配置委托
for (const configure of this.configureServicesDelegates) {
await configure(this.builderContext, this.serviceCollection)
}
const provider = this.serviceCollection.buildServiceProvider() // 构建服务提供者
const application = new PluginHostApplication(
this.builderContext,
provider,
this.configureDelegates,
this.middleware,
this.hostedServices
)
return new PluginHost(application.withExposures(this.exposures)) // 返回包装了暴露服务的Host
}
// 标准化暴露解析器
private normalizeResolver(
resolver: HostExposureResolver
): ((provider: ServiceProvider) => unknown) | null {
if (resolver.token) {
return (provider) => provider.get(resolver.token as ServiceToken) // 通过令牌获取服务
}
if (resolver.factory) {
return resolver.factory // 使用工厂函数
}
return null
}
}
// 插件Host类,管理应用的启动和停止
export class PluginHost {
constructor(private readonly app: PluginHostApplicationWithExposures) {}
// 获取服务提供者
get services() {
return this.app.services
}
// 获取Host上下文
get hostContext() {
return this.app.hostContext
}
// 启动应用
async start() {
await this.app.start()
}
// 停止应用
async stop() {
await this.app.stop()
}
// 释放资源
async dispose() {
await this.app.dispose()
}
// 运行应用,返回停止函数
async run() {
await this.start()
return async () => {
await this.dispose()
}
}
}
// 创建Host构建器的工厂函数
export function createPluginHostBuilder(ctx: PluginRuntimeContext, settings?: HostBuilderSettings) {
return new ExamAwareHostBuilder(ctx, settings)
}
// Host工具类,提供创建构建器的静态方法
export const Host = {
createApplicationBuilder: createPluginHostBuilder,
}
// 定义插件的辅助函数,使用Host构建器配置插件
export function defineExamAwarePlugin(
setup: (builder: ExamAwareHostBuilder) => Promise<void> | void
) {
return async function examAwarePlugin(ctx: PluginRuntimeContext) {
const builder = Host.createApplicationBuilder(ctx)
await setup(builder)
const host = await builder.build()
return host.run() // 返回运行函数,插件加载时调用
}
}
-30
View File
@@ -1,30 +0,0 @@
export {
ExamAwareHostBuilder,
PluginHost,
createPluginHostBuilder,
Host,
defineExamAwarePlugin,
} from "./hostBuilder"
export { ServiceCollection, ServiceProvider } from "./serviceCollection"
export {
PluginContextToken,
PluginLoggerToken,
PluginSettingsToken,
DesktopApiToken,
HostApplicationLifetimeToken,
} from "./tokens"
export type {
PluginRuntimeContext,
PluginLogger,
PluginSettingsAPI,
ServiceAPI,
HostedService,
PluginMiddleware,
HostBuilderSettings,
HostBuilderContext,
PluginHostApplicationLifetime,
ConfigureServicesDelegate,
ConfigureHostDelegate,
PluginHostApplicationContext,
ServiceToken,
} from "./types"
-61
View File
@@ -1,61 +0,0 @@
import type { Awaitable, Disposer, PluginHostApplicationLifetime } from "./types"
type LoggerLike = { error: (...args: any[]) => void }
// 默认的应用生命周期实现,管理启动/停止事件
export class DefaultHostApplicationLifetime implements PluginHostApplicationLifetime {
constructor(private readonly logger: LoggerLike = console) {}
private readonly started = new Set<() => Awaitable<void>>() // 启动事件处理器
private readonly stopping = new Set<() => Awaitable<void>>() // 停止事件处理器
private readonly stopped = new Set<() => Awaitable<void>>() // 已停止事件处理器
// 注册启动事件处理器
onStarted(handler: () => Awaitable<void>): Disposer {
this.started.add(handler)
return () => {
this.started.delete(handler)
} // 返回清理函数
}
// 注册停止事件处理器
onStopping(handler: () => Awaitable<void>): Disposer {
this.stopping.add(handler)
return () => {
this.stopping.delete(handler)
}
}
// 注册已停止事件处理器
onStopped(handler: () => Awaitable<void>): Disposer {
this.stopped.add(handler)
return () => {
this.stopped.delete(handler)
}
}
// 通知所有启动事件处理器
async notifyStarted() {
await this.dispatch(this.started)
}
// 通知所有停止事件处理器
async notifyStopping() {
await this.dispatch(this.stopping)
}
// 通知所有已停止事件处理器
async notifyStopped() {
await this.dispatch(this.stopped)
}
// 执行事件处理器列表
private async dispatch(targets: Set<() => Awaitable<void>>) {
for (const handler of Array.from(targets)) {
try {
await handler()
} catch (error) {
this.logger.error("[PluginHostLifetime] handler failed", error as Error) // 记录错误但不中断
}
}
}
}
-237
View File
@@ -1,237 +0,0 @@
import {
DesktopApiToken,
HostApplicationLifetimeToken,
PluginContextToken,
PluginLoggerToken,
PluginSettingsToken,
} from "./tokens"
import { DefaultHostApplicationLifetime } from "./lifetime"
import type {
Disposer,
InjectableClass,
PluginRuntimeContext,
ServiceDescriptor,
ServiceFactory,
ServiceFactoryOrValue,
ServiceLifetime,
ServiceToken,
} from "./types"
// 检查值是否为构造函数
function isConstructor<T>(value: ServiceFactoryOrValue<T>): value is InjectableClass<T> {
return (
typeof value === "function" &&
!!(value as any).prototype &&
(value as any).prototype.constructor === value
)
}
// 服务集合类,管理所有注册的服务描述符
export class ServiceCollection {
private readonly descriptors = new Map<ServiceToken, ServiceDescriptor>() // 服务描述符映射
private readonly hostLifetime: DefaultHostApplicationLifetime // 应用生命周期管理器
constructor(private readonly ctx: PluginRuntimeContext) {
this.hostLifetime = new DefaultHostApplicationLifetime((ctx as any).logger ?? console)
// 预注册常用服务
this.addSingleton(PluginContextToken, () => ctx)
this.addSingleton(PluginLoggerToken, () => ctx.logger)
this.addSingleton(PluginSettingsToken, () => ctx.settings)
this.addSingleton(DesktopApiToken, () => ctx.desktopApi)
this.addSingleton(HostApplicationLifetimeToken, () => this.hostLifetime)
}
// 获取生命周期管理器
getLifetime() {
return this.hostLifetime
}
// 添加单例服务
addSingleton<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "singleton", impl)
}
// 添加作用域服务
addScoped<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "scoped", impl)
}
// 添加瞬时服务
addTransient<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "transient", impl)
}
// 尝试添加单例服务(如果不存在)
tryAddSingleton<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
if (!this.descriptors.has(token)) {
this.addSingleton(token, impl)
}
return this
}
// 检查服务是否已注册
has(token: ServiceToken): boolean {
return this.descriptors.has(token)
}
// 清空所有服务
clear(): void {
this.descriptors.clear()
}
// 构建服务提供者
buildServiceProvider(): ServiceProvider {
return new ServiceProvider(this.ctx, new Map(this.descriptors))
}
// 注册服务
private register<T>(
token: ServiceToken<T>,
lifetime: ServiceLifetime,
impl: ServiceFactoryOrValue<T>
): this {
const descriptor: ServiceDescriptor = {
token,
lifetime,
factory: this.normalizeFactory(impl), // 标准化工厂函数
}
this.descriptors.set(token, descriptor)
return this
}
// 标准化工厂函数
private normalizeFactory<T>(impl: ServiceFactoryOrValue<T>): ServiceFactory<T> {
if (isConstructor(impl)) {
return (provider) => this.instantiateClass(impl, provider) // 构造函数,实例化类
}
if (typeof impl === "function") {
return impl as ServiceFactory<T> // 已经是工厂函数
}
return () => impl // 直接值,返回常量
}
// 实例化类,注入依赖
private instantiateClass<T>(Ctor: InjectableClass<T>, provider: ServiceProvider): T {
const deps = [...(Ctor.inject ?? [])].map((token) => provider.get(token)) // 获取依赖
return new Ctor(...deps) // 构造实例
}
}
// 服务提供者类,负责解析和提供服务实例
export class ServiceProvider {
private readonly singletonCache: Map<ServiceToken, unknown> // 单例缓存
private readonly scopedCache = new Map<ServiceToken, unknown>() // 作用域缓存
private readonly singletonCleanup: Disposer[] // 单例清理函数
private readonly scopedCleanup: Disposer[] = [] // 作用域清理函数
private readonly root: ServiceProvider | null // 根提供者
constructor(
private readonly ctx: PluginRuntimeContext,
private readonly descriptors: Map<ServiceToken, ServiceDescriptor>,
root: ServiceProvider | null = null,
private readonly isScope = false // 是否为作用域提供者
) {
this.root = root
this.singletonCache = root ? root.singletonCache : new Map() // 共享单例缓存
this.singletonCleanup = root ? root.singletonCleanup : []
}
// 获取服务实例
get<T>(token: ServiceToken<T>): T {
const descriptor = this.descriptors.get(token) as ServiceDescriptor<T> | undefined
if (!descriptor) {
const hostValue = this.resolveFromHost<T>(token) // 尝试从宿主获取
if (hostValue !== undefined) return hostValue
throw new Error(`Service not registered for token: ${token.toString?.() ?? String(token)}`)
}
switch (descriptor.lifetime) {
case "singleton":
return this.resolveSingleton(descriptor)
case "scoped":
return this.resolveScoped(descriptor)
case "transient":
default:
return this.instantiate(descriptor) // 每次都新实例
}
}
// 创建作用域提供者
createScope(): ServiceProvider {
return new ServiceProvider(this.ctx, this.descriptors, this.root ?? this, true)
}
// 释放资源
async dispose(): Promise<void> {
await this.flushCleanup(this.scopedCleanup) // 先清理作用域
if (!this.isScope) {
await this.flushCleanup(this.singletonCleanup) // 再清理单例
this.singletonCache.clear()
}
this.scopedCache.clear()
}
// 解析单例服务
private resolveSingleton<T>(descriptor: ServiceDescriptor<T>): T {
const cacheOwner = this.root ?? this
if (cacheOwner.singletonCache.has(descriptor.token)) {
return cacheOwner.singletonCache.get(descriptor.token) as T
}
const instance = this.instantiate(descriptor)
cacheOwner.singletonCache.set(descriptor.token, instance)
const disposer = this.extractDisposer(instance) // 提取清理函数
if (disposer) cacheOwner.singletonCleanup.push(disposer)
return instance
}
// 解析作用域服务
private resolveScoped<T>(descriptor: ServiceDescriptor<T>): T {
if (this.scopedCache.has(descriptor.token)) {
return this.scopedCache.get(descriptor.token) as T
}
const instance = this.instantiate(descriptor)
this.scopedCache.set(descriptor.token, instance)
const disposer = this.extractDisposer(instance)
if (disposer) this.scopedCleanup.push(disposer)
return instance
}
// 实例化服务
private instantiate<T>(descriptor: ServiceDescriptor<T>): T {
return descriptor.factory(this)
}
// 从宿主服务API解析
private resolveFromHost<T>(token: ServiceToken<T>): T | undefined {
if (typeof token === "string" && this.ctx.services?.has?.(token)) {
return this.ctx.services.inject<T>(token)
}
return undefined
}
// 提取实例的清理函数
private extractDisposer(instance: unknown): Disposer | null {
if (!instance) return null
if (typeof (instance as any).dispose === "function") {
return () => (instance as any).dispose()
}
if (typeof (instance as any).destroy === "function") {
return () => (instance as any).destroy()
}
if (typeof (instance as any).stop === "function") {
return () => (instance as any).stop()
}
return null
}
// 执行清理函数列表
private async flushCleanup(cleanup: Disposer[]) {
while (cleanup.length) {
const disposer = cleanup.pop()
if (!disposer) continue
await disposer()
}
}
}
-17
View File
@@ -1,17 +0,0 @@
// 服务令牌定义,用于依赖注入
// 使用Symbol确保唯一性,避免字符串冲突
// 插件运行时上下文令牌
export const PluginContextToken = Symbol.for("examaware.hosting.ctx")
// 插件日志器令牌
export const PluginLoggerToken = Symbol.for("examaware.hosting.logger")
// 插件设置API令牌
export const PluginSettingsToken = Symbol.for("examaware.hosting.settings")
// Desktop API令牌
export const DesktopApiToken = Symbol.for("examaware.hosting.desktopApi")
// 应用生命周期管理器令牌
export const HostApplicationLifetimeToken = Symbol.for("examaware.hosting.lifetime")
-150
View File
@@ -1,150 +0,0 @@
import type { ServiceProvideOptions, ServiceWatcherMeta } from "../../shared/services/registry"
import type { ServiceCollection, ServiceProvider } from "./serviceCollection"
// 基础类型定义
export type Awaitable<T> = T | Promise<T> // 可以是同步值或Promise
export type Disposer = () => Awaitable<void> // 清理函数,返回void或Promise<void>
// 服务令牌,可以是字符串、符号或构造函数
export type ServiceToken<T = unknown> = string | symbol | (new (...args: any[]) => T)
// 可注入的类,带可选的inject属性声明依赖
export interface InjectableClass<T = unknown> {
new (...args: any[]): T
inject?: readonly ServiceToken[] // 依赖的令牌列表
}
// 服务工厂函数,从provider获取实例
export type ServiceFactory<T> = (provider: ServiceProvider) => T
// 服务实现,可以是工厂函数、构造函数或直接值
export type ServiceFactoryOrValue<T> = ServiceFactory<T> | InjectableClass<T> | T
// 服务生命周期:单例、作用域内单例、每次都新实例
export type ServiceLifetime = "singleton" | "scoped" | "transient"
// 服务描述符,定义如何创建服务
export interface ServiceDescriptor<T = unknown> {
token: ServiceToken<T>
lifetime: ServiceLifetime
factory: ServiceFactory<T> // 创建实例的工厂函数
}
// 托管服务接口,有启动和停止方法
export interface HostedService {
start(): Awaitable<void>
stop(): Awaitable<void>
}
// Host构建器设置
export interface HostBuilderSettings {
environment?: string // 环境名,如'development'
properties?: Record<string, unknown> // 额外属性
}
// Host构建器上下文,包含运行时上下文和配置
export interface HostBuilderContext {
ctx: PluginRuntimeContext // 插件运行时上下文
environmentName: string // 当前环境
properties: Map<string | symbol, unknown> // 属性映射
lifetime: PluginHostApplicationLifetime // 应用生命周期管理
}
// 插件应用上下文,包含服务提供者和上下文
export interface PluginHostApplicationContext {
ctx: PluginRuntimeContext
services: ServiceProvider
host: HostBuilderContext
}
// 配置服务委托,在构建时注册服务
export type ConfigureServicesDelegate = (
context: HostBuilderContext,
services: ServiceCollection
) => Awaitable<void>
// 配置Host委托,在应用启动时执行
export type ConfigureHostDelegate = (
context: HostBuilderContext,
app: PluginHostApplicationContext
) => Awaitable<void>
// 中间件函数,包装应用逻辑
export type PluginMiddleware = (
app: PluginHostApplicationContext,
next: () => Promise<void>
) => Awaitable<void>
// 暴露服务解析器,可以通过令牌或工厂函数
export interface HostExposureResolver<T = unknown> {
token?: ServiceToken<T> // 通过令牌暴露
factory?: (provider: ServiceProvider) => T // 通过工厂函数暴露
}
// 服务API接口,插件用来注册和获取服务
export interface ServiceAPI {
provide: (name: string, value: unknown, options?: ServiceProvideOptions) => Disposer
inject: <T = unknown>(name: string, owner?: string) => T
injectAsync?: <T = unknown>(name: string, owner?: string) => Promise<T>
when?: <T = unknown>(
name: string,
cb: (svc: T, owner: string, meta: ServiceWatcherMeta) => void | (() => void)
) => Disposer
has: (name: string, owner?: string) => boolean
}
// 插件日志接口
export interface PluginLogger {
info: (...args: any[]) => void
warn: (...args: any[]) => void
error: (...args: any[]) => void
debug?: (...args: any[]) => void
}
// 插件设置API,读写配置
export interface PluginSettingsAPI {
all(): Record<string, any> // 获取所有配置
get<T = unknown>(key?: string, def?: T): T // 获取单个配置项
set<T = unknown>(key: string, value: T): Promise<void> // 设置配置项
patch(partial: Record<string, any>): Promise<void> // 批量更新配置
reset(): Promise<void> // 重置配置
onChange(listener: (config: Record<string, any>) => void): Disposer // 监听配置变化
}
// 插件运行时上下文,插件的核心接口
export interface PluginRuntimeContext {
app: "main" | "renderer" // 运行在主进程还是渲染进程
logger: PluginLogger // 日志器
config: Record<string, any> // 插件配置
settings: PluginSettingsAPI // 设置API
effect: (fn: () => void | Disposer | Promise<void | Disposer>) => void // 注册副作用清理
services: ServiceAPI // 服务API
windows?: {
// 窗口操作(主进程)
broadcast: (channel: string, payload?: any) => void
}
ipc?: {
// IPC通信(主进程)
registerChannel: (channel: string, handler: (event: unknown, ...args: any[]) => any) => Disposer
invokeRenderer?: (channel: string, payload?: any) => void
}
desktopApi?: unknown // Desktop API(渲染进程)
}
// 插件应用生命周期接口,管理启动/停止事件
export interface PluginHostApplicationLifetime {
onStarted(handler: () => Awaitable<void>): Disposer // 监听启动事件
onStopping(handler: () => Awaitable<void>): Disposer // 监听停止事件
onStopped(handler: () => Awaitable<void>): Disposer // 监听已停止事件
notifyStarted(): Promise<void> // 触发启动通知
notifyStopping(): Promise<void> // 触发停止通知
notifyStopped(): Promise<void> // 触发已停止通知
}
// 暴露的服务定义
export type HostExposure = {
name: string // 服务名
resolver: (provider: ServiceProvider) => unknown // 解析函数
}
// 重新导出类型,便于使用
export type { ServiceCollection, ServiceProvider }
+1 -1
View File
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
按顺序完成任务:
1.请你编写一个应用内的统一的强大的规范的配置管理服务,持久化存储,能在任何地方简单访问,且能够和renderer的组件绑定,设置项一旦修改实时保存。
2.现在请你阅读(请见引用部分1)下的源代码。这是一个从ExamAware项目拷贝来的依赖注入实现源代码。请你将它改写,以适配我的SecScore项目程序,并在所有的main源代码中使用统一的di来管理相关内容。
然后:请你帮我写一个统一的窗口管理器,用于管理所有程序的窗口,并引入相关依赖(需适配当前架构)做多窗口多页面支持。
3.(请见引用部分2)请参照Koishi的Disposable设计,将项目的代码规范化、统一化、标准化,做到高度抽象化、模块化、高可维护性、面向对象性、可拓展性,拆分、重写部分源代码,符合typescript规范
4.请你引入Winston(要与rust对应)日志库,并编写完备强大的应用内日志服务,并将所有代码相关的日志接入,完全替代console log等。日志分级,多色清晰打印,支持按日期存储日志文件。
5.将设置单独分离成窗口,然后将设置页把原有的标签栏设计改为侧边栏设计
6.把除了基本积分功能(首页,排行榜,学生管理,积分操作,理由管理)外的其他功能改为可供插件系统安装/卸载的内置插件,即不需要下载的插件,而且初次使用时默认不安装,需要用户自行安装
7.根据软件现有情况把SecScore的readme更新一下
(请注意:安装依赖等使用的包管理器是pnpm)
+46 -155
View File
@@ -7,198 +7,109 @@
<a href="https://github.com/SECTL/SecScore/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/SECTL/SecScore?style=flat-square"></a>
<a href="https://github.com/SECTL/SecScore/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SECTL/SecScore?style=flat-square"></a>
<a href="https://github.com/SECTL/SecScore/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/SECTL/SecScore?style=flat-square"></a>
<img alt="Tauri" src="https://img.shields.io/badge/Tauri-2+-24C8D8?style=flat-square&logo=tauri">
<img alt="Electron" src="https://img.shields.io/badge/Electron-39+-47848F?style=flat-square&logo=electron">
<img alt="React" src="https://img.shields.io/badge/React-19+-61DAFB?style=flat-square&logo=react">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5+-3178C6?style=flat-square&logo=typescript">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7+-646CFF?style=flat-square&logo=vite">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.8+-000000?style=flat-square&logo=rust">
</p>
SecScore 是一款现代化的教育积分管理软件,基于 **Tauri + React + TypeScript + Rust** 开发,采用可逆插件架构设计,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护数据备份与云同步功能
## 核心特性
### 🏗️ 现代化架构
- **可逆插件系统**:参照 Koishi 的 Disposable 设计理念,所有非核心功能均为可安装/卸载的内置插件
- **依赖注入(DI)**:统一的依赖管理,支持服务注册与注入
- **配置管理**:统一的配置服务,支持持久化存储与实时同步
- **日志系统**:分级日志,支持多色打印与文件存储
### 🔌 内置插件系统
SecScore 将非核心功能设计为**内置插件**,默认不安装,用户可按需启用:
| 插件 | 功能描述 | 默认状态 |
| -------- | ---------------------------------------------- | -------- |
| 自动评分 | 基于规则的自动评分系统,支持定时任务和条件触发 | 未安装 |
| 看板管理 | 可视化数据看板,支持自定义布局和实时数据展示 | 未安装 |
| 结算历史 | 积分结算记录管理,支持导出和统计分析 | 未安装 |
| 奖励设置 | 奖励兑换系统配置,管理奖励项目和兑换规则 | 未安装 |
| 云同步 | SECTL 云服务集成,支持数据云同步和跨设备访问 | 未安装 |
**核心功能**(始终可用):
- 首页(积分操作)
- 学生管理
- 排行榜
- 理由管理
- 设置
### 🔒 安全与权限
- **多级权限**:管理员 / 积分操作员 / 只读
- **密码保护**:支持管理密码和积分密码
- **找回机制**:提供找回字符串用于密码重置
- **本地优先**:所有数据本地存储,无需联网
### ☁️ 云同步(可选)
- **SECTL 云服务**:支持数据云同步和跨设备访问
- **自动同步**:检测到数据变化时自动同步
- **冲突解决**:智能处理本地与远程数据冲突
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护数据备份。
## 主要功能
### 学生管理
- 添加/删除学生
- 通过 xlsx 批量导入名单(支持导入前预览与选择"姓名列")
- 支持学生头像自动生成
### 积分管理
- 选择学生并提交加分/扣分
- 支持"预设理由"一键填充理由与分值
- 支持撤销最近的积分记录(撤销后学生积分会回滚)
- 支持沉浸模式,专注积分操作
### 理由管理
- 维护"预设理由"(分类、理由内容、预设分值)
- 支持标签分类管理
### 排行榜
- 支持按"今天 / 本周 / 本月"查看积分变化
- 支持导出排行榜为 XLSX
- 支持查看单个学生的操作记录(文本列表)
### 结算与历史
- "结算并重新开始":把当前未结算记录归档为一个阶段,并将所有学生当前积分清零
- 在"结算历史"查看每个阶段的排行榜(需安装插件)
### 系统设置
- 主题切换(支持自定义主题)
- 多语言支持(10+ 语言)
- 日志查看/导出/清空
- 数据导入/导出(JSON
- 密码保护(管理密码 / 积分密码)与找回字符串
- 数据库切换(SQLite / PostgreSQL
- 学生管理
- 添加/删除学生
- 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)
- 积分管理
- 选择学生并提交加分/扣分
- 支持“预设理由”一键填充理由与分值
- 支持撤销最近的积分记录(撤销后学生积分会回滚)
- 理由管理
- 维护“预设理由”(分类、理由内容、预设分值)
- 排行榜
- 支持按“今天 / 本周 / 本月”查看积分变化
- 支持导出排行榜为 XLSX
- 支持查看单个学生的操作记录(文本列表)
- 结算与历史
- “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零
- 在“结算历史”查看每个阶段的排行榜
- 系统设置
- 主题切换
- 日志查看/导出/清空
- 数据导入/导出(JSON
- 密码保护(管理密码 / 积分密码)与找回字符串
## 使用方法
### 1. 权限与解锁
- 右上角会显示当前权限:管理权限 / 积分权限 / 只读
- 若设置了密码,可通过右上角"输入密码"进行解锁
- 若设置了密码,可通过右上角输入密码进行解锁
- 管理密码:全功能(学生管理、理由管理、结算、数据管理等)
- 积分密码:仅允许积分相关操作
- 点击"锁定"可切回只读状态
- 点击锁定可切回只读状态
- 无密码时默认视为管理权限
### 2. 安装内置插件
入口:左侧菜单 → 插件管理 → 内置插件
1. 浏览可用的内置插件列表
2. 点击"安装"按钮安装需要的插件
3. 安装后点击"启用"激活插件
4. 插件功能将自动显示在侧边栏菜单中
### 3. 学生管理(导入名单)
### 2. 学生管理(导入名单)
入口:左侧菜单 → 学生管理 → 导入名单
- 通过 xlsx 导入
1. 选择一个 `.xlsx` 文件(默认读取第一个工作表)
2. 在预览表格里点击表头,选择"姓名列"
3. 点击"导入"
4. 导入完成后会提示"新增 / 跳过"数量
2. 在预览表格里点击表头,选择姓名列
3. 点击导入
4. 导入完成后会提示新增 / 跳过数量
建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。
### 4. 积分管理(加分/扣分)
### 3. 积分管理(加分/扣分)
入口:左侧菜单 → 积分管理
1."姓名"选择一个学生(支持搜索)
2. 选择"加分/扣分",并输入分值
3."理由内容"填写原因(可手动输入)
4. 点击"确认提交"
1.姓名选择一个学生(支持搜索)
2. 选择加分/扣分,并输入分值
3.理由内容填写原因(可手动输入)
4. 点击确认提交
快捷理由:
- 可在"快捷理由"下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)
- 可在快捷理由下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)
撤销最近记录:
- "最近记录"默认折叠,展开后可对记录点击"撤销"
- 最近记录默认折叠,展开后可对记录点击撤销
- 撤销会回滚该条记录对学生积分的影响
### 5. 排行榜与导出
### 4. 排行榜与导出
入口:左侧菜单 → 排行榜
- 可切换统计范围:今天 / 本周 / 本月
- 点击"导出 XLSX"可导出当前排行榜
- 点击某个学生的"查看"可打开该学生的操作记录
- 点击导出 XLSX可导出当前排行榜
- 点击某个学生的查看可打开该学生的操作记录
### 6. 结算与数据备份
### 5. 结算与数据备份
入口:左侧菜单 → 系统设置 → 数据管理
- 结算并重新开始(需安装"结算历史"插件)
- 结算并重新开始
- 会把当前未结算的积分记录归档为一个阶段
- 会将所有学生当前积分清零
- 学生名单不变;结算后的历史在"结算历史"查看
- 学生名单不变;结算后的历史在结算历史查看
- 导出 JSON(强烈建议定期备份)
- 导入会覆盖现有学生/理由/积分记录/设置
- 安全相关设置(密码等)不会随导入写入
## 技术架构
### 前端
- **框架**React 19 + TypeScript 5
- **构建工具**Vite 7
- **UI 组件**Ant Design 5
- **状态管理**Context API + 自定义 DI 系统
- **国际化**i18next
### 后端
- **框架**Tauri 2 + Rust
- **数据库**SQLite(本地)/ PostgreSQL(远程)
- **ORM**:自定义数据库抽象层
### 架构设计
- **可逆插件系统**:参照 Koishi 的 Disposable 设计,支持热重载
- **依赖注入**:基于 ExamAware 项目的 DI 实现
- **配置管理**:统一的 ConfigService,支持实时同步
- **日志系统**:分级日志,支持多色打印与文件存储
- **窗口管理**:统一的 WindowManager,支持多窗口
## 开发与运行(面向贡献者)
## MCP 文档
- [MCP-使用说明](./MCP-使用说明.md)
### 环境要求
- Node.js(建议使用 LTS 版本)
- pnpm
- Rust(用于 Tauri 后端)
### 安装依赖
@@ -230,24 +141,4 @@ pnpm build:unpack
```bash
pnpm lint
pnpm typecheck
pnpm format
```
## 文档
- [MCP-使用说明](./MCP-使用说明.md)
- [插件开发指南](./plugin-wiki/插件开发指南.md)
## 贡献
欢迎提交 Issue 和 Pull Request
## 许可证
[MIT License](./LICENSE)
---
<p align="center">
Made with ❤️ by SECTL
</p>
-224
View File
@@ -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 已经有超过 3000 个插件,其中的依赖错综复杂。而即使是在这个规模下,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 的一大优势。
-2
View File
@@ -32,8 +32,6 @@
"react-i18next": "^16.5.6",
"react-querybuilder": "^8.14.0",
"react-router-dom": "^6.28.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
+15 -225
View File
@@ -53,12 +53,6 @@ importers:
react-router-dom:
specifier: ^6.28.0
version: 6.30.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
winston:
specifier: ^3.19.0
version: 3.19.0
winston-daily-rotate-file:
specifier: ^5.0.0
version: 5.0.0(winston@3.19.0)
xlsx:
specifier: ^0.18.5
version: 0.18.5
@@ -233,13 +227,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@emotion/hash@0.8.0':
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
@@ -852,6 +839,7 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -863,11 +851,13 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
@@ -885,36 +875,43 @@ packages:
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -946,9 +943,6 @@ packages:
cpu: [x64]
os: [win32]
'@so-ric/colorspace@1.1.6':
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -981,30 +975,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.1':
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
@@ -1078,9 +1077,6 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/use-sync-external-store@0.0.3':
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
@@ -1217,9 +1213,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -1300,25 +1293,9 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.3:
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
color@5.0.3:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
@@ -1389,9 +1366,6 @@ packages:
electron-to-chromium@1.5.313:
resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
es-abstract@1.24.1:
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
engines: {node: '>= 0.4'}
@@ -1524,16 +1498,10 @@ packages:
picomatch:
optional: true
fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-stream-rotator@0.6.1:
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1545,9 +1513,6 @@ packages:
flatted@3.4.1:
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -1679,9 +1644,6 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -1761,10 +1723,6 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -1841,9 +1799,6 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kuler@2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1858,10 +1813,6 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1909,10 +1860,6 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -1937,9 +1884,6 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2141,10 +2085,6 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -2188,9 +2128,6 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -2199,10 +2136,6 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2270,9 +2203,6 @@ packages:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -2299,9 +2229,6 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -2317,9 +2244,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -2328,10 +2252,6 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -2391,9 +2311,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2459,20 +2376,6 @@ packages:
engines: {node: '>= 8'}
hasBin: true
winston-daily-rotate-file@5.0.0:
resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==}
engines: {node: '>=8'}
peerDependencies:
winston: ^3
winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'}
winston@3.19.0:
resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==}
engines: {node: '>= 12.0.0'}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -2668,14 +2571,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@colors/colors@1.6.0': {}
'@dabh/diagnostics@2.0.8':
dependencies:
'@so-ric/colorspace': 1.1.6
enabled: 2.0.0
kuler: 2.0.0
'@emotion/hash@0.8.0': {}
'@emotion/unitless@0.7.5': {}
@@ -3350,11 +3245,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@so-ric/colorspace@1.1.6':
dependencies:
color: 5.0.3
text-hex: 1.0.0
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -3467,8 +3357,6 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/triple-beam@1.3.5': {}
'@types/use-sync-external-store@0.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@@ -3717,8 +3605,6 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -3793,23 +3679,8 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.3:
dependencies:
color-name: 2.1.0
color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
color@5.0.3:
dependencies:
color-convert: 3.1.3
color-string: 2.1.4
compute-scroll-into-view@3.1.1: {}
concat-map@0.0.1: {}
@@ -3878,8 +3749,6 @@ snapshots:
electron-to-chromium@1.5.313: {}
enabled@2.0.0: {}
es-abstract@1.24.1:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -4134,16 +4003,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fecha@4.2.3: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
file-stream-rotator@0.6.1:
dependencies:
moment: 2.30.1
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -4156,8 +4019,6 @@ snapshots:
flatted@3.4.1: {}
fn.name@1.1.0: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -4282,8 +4143,6 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4372,8 +4231,6 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-stream@2.0.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -4450,8 +4307,6 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kuler@2.0.0: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4465,15 +4320,6 @@ snapshots:
lodash@4.17.23: {}
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
'@types/triple-beam': 1.3.5
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.5.0
triple-beam: 1.4.1
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -4513,8 +4359,6 @@ snapshots:
object-assign@4.1.1: {}
object-hash@3.0.0: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -4549,10 +4393,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
one-time@1.0.0:
dependencies:
fn.name: 1.1.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -4738,12 +4578,6 @@ snapshots:
react@19.2.4: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
@@ -4828,8 +4662,6 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -4841,8 +4673,6 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
scheduler@0.27.0: {}
scroll-into-view-if-needed@3.1.0:
@@ -4919,8 +4749,6 @@ snapshots:
dependencies:
frac: 1.1.2
stack-trace@0.0.10: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4972,10 +4800,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@3.1.1: {}
stylis@4.3.6: {}
@@ -4986,8 +4810,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
text-hex@1.0.0: {}
throttle-debounce@5.0.2: {}
tinyglobby@0.2.15:
@@ -4995,8 +4817,6 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
triple-beam@1.4.1: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -5074,8 +4894,6 @@ snapshots:
dependencies:
react: 19.2.4
util-deprecate@1.0.2: {}
vite@7.3.1(@types/node@22.19.15):
dependencies:
esbuild: 0.27.4
@@ -5135,34 +4953,6 @@ snapshots:
dependencies:
isexe: 2.0.0
winston-daily-rotate-file@5.0.0(winston@3.19.0):
dependencies:
file-stream-rotator: 0.6.1
object-hash: 3.0.0
triple-beam: 1.4.1
winston: 3.19.0
winston-transport: 4.9.0
winston-transport@4.9.0:
dependencies:
logform: 2.7.0
readable-stream: 3.6.2
triple-beam: 1.4.1
winston@3.19.0:
dependencies:
'@colors/colors': 1.6.0
'@dabh/diagnostics': 2.0.8
async: 3.2.6
is-stream: 2.0.1
logform: 2.7.0
one-time: 1.0.0
readable-stream: 3.6.2
safe-stable-stringify: 2.5.0
stack-trace: 0.0.10
triple-beam: 1.4.1
winston-transport: 4.9.0
wmf@1.0.2: {}
word-wrap@1.2.5: {}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logoHD.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SecScore 设置</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/settings-window.tsx"></script>
</body>
</html>
-58
View File
@@ -169,61 +169,3 @@ pub async fn window_start_dragging(
}
Ok(())
}
#[tauri::command]
pub async fn open_settings_window(app: AppHandle) -> Result<(), String> {
#[cfg(not(desktop))]
{
let _ = app;
return Err("Not supported on mobile".to_string());
}
#[cfg(desktop)]
{
// 检查设置窗口是否已存在
if let Some(window) = app.get_webview_window("settings") {
// 如果窗口已存在,将其置于前台
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
// 创建新的设置窗口
let window = tauri::WebviewWindowBuilder::new(
&app,
"settings",
tauri::WebviewUrl::App("/settings-window".into()),
)
.title("SecScore 设置")
.inner_size(900.0, 650.0)
.min_inner_size(700.0, 500.0)
.center()
.decorations(false)
.transparent(false)
.resizable(true)
.build()
.map_err(|e| e.to_string())?;
// 在开发模式下打开开发者工具
#[cfg(debug_assertions)]
window.open_devtools();
Ok(())
}
}
#[tauri::command]
pub async fn close_settings_window(app: AppHandle) -> Result<(), String> {
#[cfg(not(desktop))]
{
let _ = app;
return Ok(());
}
#[cfg(desktop)]
{
if let Some(window) = app.get_webview_window("settings") {
window.close().map_err(|e| e.to_string())?;
}
Ok(())
}
}
-2
View File
@@ -131,8 +131,6 @@ pub fn run() {
toggle_devtools,
window_resize,
window_set_resizable,
open_settings_window,
close_settings_window,
db_test_connection,
db_switch_connection,
db_get_status,
-56
View File
@@ -1,63 +1,7 @@
import { Context } from "./shared/kernel"
import { appRegistry } from "./di/AppServiceRegistry"
import { PluginRuntimeContext } from "./di"
export class ClientContext extends Context {
private diInitialized = false
constructor() {
super()
}
/**
* 初始化 DI 系统
*/
initializeDI(): void {
if (this.diInitialized) return
const runtimeContext: PluginRuntimeContext = {
app: "renderer",
logger: {
info: (...args: any[]) => console.log("[Renderer]", ...args),
warn: (...args: any[]) => console.warn("[Renderer]", ...args),
error: (...args: any[]) => console.error("[Renderer]", ...args),
debug: (...args: any[]) => console.debug("[Renderer]", ...args),
},
config: {},
settings: {
all: () => ({}),
get: <T = unknown>(_key?: string, _def?: T) => ({}) as T,
set: async () => {},
patch: async () => {},
reset: async () => {},
onChange: () => () => {},
},
effect: (fn: () => void | (() => void) | Promise<void | (() => void)>) => {
const disposer = fn()
if (typeof disposer === "function") {
this.effect(disposer)
} else if (disposer && typeof (disposer as any).then === "function") {
;(disposer as Promise<void>).then((d) => {
if (typeof d === "function") this.effect(d)
})
}
},
services: {
provide: () => () => {},
inject: <T = unknown>(_name: string, _owner?: string) => ({}) as T,
has: () => false,
},
desktopApi: (window as any).api,
}
appRegistry.initialize(runtimeContext)
this.diInitialized = true
}
/**
* 获取服务注册表
*/
getRegistry() {
return appRegistry
}
}
+6 -6
View File
@@ -630,9 +630,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
: ""
})()
: draft.event === "settle_score"
? ""
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
: draft.event === "settle_score"
? ""
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
} satisfies ActionDraft
})
.filter((item): item is ActionDraft => Boolean(item))
@@ -664,9 +664,9 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
: ""
})()
: action.event === "settle_score"
? ""
: toStringValue(action.value),
: action.event === "settle_score"
? ""
: toStringValue(action.value),
} satisfies ActionDraft
})
.filter((item): item is ActionDraft => Boolean(item))
-193
View File
@@ -1,193 +0,0 @@
import React from "react"
import { Card, List, Button, Tag, Space, Typography, Spin, Empty, message } from "antd"
import {
ThunderboltOutlined,
DashboardOutlined,
HistoryOutlined,
CloudOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DownloadOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { useBuiltinPlugins } from "../hooks/useBuiltinPlugins"
import { BuiltinPluginMeta } from "../plugins/builtin"
const { Title, Text, Paragraph } = Typography
const categoryIcons: Record<string, React.ReactNode> = {
automation: <ThunderboltOutlined />,
visualization: <DashboardOutlined />,
management: <HistoryOutlined />,
integration: <CloudOutlined />,
}
const categoryLabels: Record<string, string> = {
automation: "自动化",
visualization: "可视化",
management: "管理",
integration: "集成",
}
interface BuiltinPluginManagerProps {
canEdit: boolean
}
export const BuiltinPluginManager: React.FC<BuiltinPluginManagerProps> = ({ canEdit }) => {
const { plugins, loading, install, uninstall, enable, disable } = useBuiltinPlugins()
const [messageApi, contextHolder] = message.useMessage()
const handleInstall = async (pluginId: string) => {
try {
await install(pluginId)
messageApi.success("插件安装成功")
} catch (error) {
messageApi.error("插件安装失败")
}
}
const handleUninstall = async (pluginId: string) => {
try {
await uninstall(pluginId)
messageApi.success("插件已卸载")
} catch (error) {
messageApi.error("插件卸载失败")
}
}
const handleEnable = async (pluginId: string) => {
try {
await enable(pluginId)
messageApi.success("插件已启用")
} catch (error) {
messageApi.error("插件启用失败")
}
}
const handleDisable = async (pluginId: string) => {
try {
await disable(pluginId)
messageApi.success("插件已禁用")
} catch (error) {
messageApi.error("插件禁用失败")
}
}
const renderPluginActions = (
plugin: BuiltinPluginMeta & { installed: boolean; enabled: boolean }
) => {
if (!plugin.installed) {
return (
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => handleInstall(plugin.id)}
disabled={!canEdit}
>
</Button>
)
}
return (
<Space>
{plugin.enabled ? (
<Button
type="default"
icon={<CloseCircleOutlined />}
onClick={() => handleDisable(plugin.id)}
disabled={!canEdit}
>
</Button>
) : (
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => handleEnable(plugin.id)}
disabled={!canEdit}
>
</Button>
)}
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleUninstall(plugin.id)}
disabled={!canEdit}
>
</Button>
</Space>
)
}
return (
<div style={{ padding: "24px" }}>
{contextHolder}
<Title level={4}></Title>
<Paragraph type="secondary">
SecScore 访
</Paragraph>
{loading ? (
<div style={{ textAlign: "center", padding: "40px" }}>
<Spin size="large" />
</div>
) : plugins.length === 0 ? (
<Empty description="暂无可用插件" />
) : (
<List
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
dataSource={plugins}
renderItem={(plugin) => (
<List.Item>
<Card
hoverable
style={{
backgroundColor: "var(--ss-card-bg)",
borderColor: plugin.enabled
? "var(--ant-color-primary)"
: "var(--ss-border-color)",
}}
title={
<Space>
{categoryIcons[plugin.category]}
<span>{plugin.name}</span>
</Space>
}
extra={
<Tag color={plugin.enabled ? "success" : "default"}>
{plugin.enabled ? "已启用" : plugin.installed ? "已安装" : "未安装"}
</Tag>
}
>
<div style={{ marginBottom: "12px" }}>
<Text type="secondary">{plugin.description}</Text>
</div>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div>
<Tag>{categoryLabels[plugin.category]}</Tag>
{plugin.requiresAdmin && <Tag color="warning"></Tag>}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text type="secondary" style={{ fontSize: "12px" }}>
{plugin.version}
</Text>
{renderPluginActions(plugin)}
</div>
</Space>
</Card>
</List.Item>
)}
/>
)}
</div>
)
}
+15 -27
View File
@@ -11,10 +11,10 @@ import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-
import { useTranslation } from "react-i18next"
import { WindowControls } from "./WindowControls"
import appLogo from "../assets/logoHD.svg"
import { usePluginEnabled } from "../hooks/useBuiltinPlugins"
const loadHome = () => import("./Home")
const loadStudentManager = () => import("./StudentManager")
const loadSettings = () => import("./Settings")
const loadReasonManager = () => import("./ReasonManager")
const loadScoreManager = () => import("./ScoreManager")
const loadAutoScoreManager = () => import("./AutoScoreManager")
@@ -26,6 +26,7 @@ const loadPluginManager = () => import("./PluginManager")
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
const Settings = lazy(() => loadSettings().then((m) => ({ default: m.Settings })))
const ReasonManager = lazy(() => loadReasonManager().then((m) => ({ default: m.ReasonManager })))
const ScoreManager = lazy(() => loadScoreManager().then((m) => ({ default: m.ScoreManager })))
const AutoScoreManager = lazy(loadAutoScoreManager)
@@ -41,6 +42,7 @@ const warmupRouteChunks = () =>
Promise.allSettled([
loadHome(),
loadStudentManager(),
loadSettings(),
loadReasonManager(),
loadScoreManager(),
loadAutoScoreManager(),
@@ -112,13 +114,6 @@ export function ContentArea({
const isPrimaryMobilePage =
normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings")
const showMobileBack = isMobileHeaderMode && !isPrimaryMobilePage
// 检查插件启用状态
const autoScoreEnabled = usePluginEnabled("auto-score")
const boardsEnabled = usePluginEnabled("boards")
const settlementsEnabled = usePluginEnabled("settlements")
const rewardSettingsEnabled = usePluginEnabled("reward-settings")
const mobilePageTitle = (() => {
if (normalizedPath.startsWith("/home")) return t("sidebar.home")
if (normalizedPath.startsWith("/students")) return t("sidebar.students")
@@ -669,28 +664,20 @@ export function ContentArea({
<ScoreManager canEdit={permission === "admin" || permission === "points"} />
}
/>
{autoScoreEnabled && (
<Route
path="/auto-score"
element={<AutoScoreManager canEdit={permission === "admin"} />}
/>
)}
{boardsEnabled && (
<Route
path="/boards"
element={<BoardManager canManage={permission === "admin"} />}
/>
)}
<Route
path="/auto-score"
element={<AutoScoreManager canEdit={permission === "admin"} />}
/>
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
<Route path="/leaderboard" element={<Leaderboard />} />
{settlementsEnabled && <Route path="/settlements" element={<SettlementHistory />} />}
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
{rewardSettingsEnabled && (
<Route
path="/reward-settings"
element={<RewardSettings canEdit={permission === "admin"} />}
/>
)}
<Route
path="/reward-settings"
element={<RewardSettings canEdit={permission === "admin"} />}
/>
<Route path="/plugins" element={<PluginManager canEdit={permission === "admin"} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
@@ -699,3 +686,4 @@ export function ContentArea({
</Layout>
)
}
+34 -55
View File
@@ -14,12 +14,10 @@ import {
Descriptions,
Upload,
Tooltip,
Tabs,
} from "antd"
import type { ColumnsType } from "antd/es/table"
import { useTranslation } from "react-i18next"
import { UploadOutlined, DeleteOutlined, FolderOpenOutlined } from "@ant-design/icons"
import { BuiltinPluginManager } from "./BuiltinPluginManager"
interface Plugin {
id: string
@@ -51,8 +49,6 @@ export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [installLoading, setInstallLoading] = useState(false)
const [selectedPath, setSelectedPath] = useState<string>("")
const [messageApi, contextHolder] = message.useMessage()
const [activeTab, setActiveTab] = useState("external")
const emitPluginsUpdated = (action: "install" | "uninstall" | "toggle", pluginId?: string) => {
window.dispatchEvent(
new CustomEvent("ss:plugins-updated", {
@@ -236,61 +232,44 @@ export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
},
]
const tabItems = [
{
key: "external",
label: "外部插件",
children: (
<>
<Card size="small" style={{ marginBottom: 16 }}>
<Descriptions size="small" column={3}>
<Descriptions.Item label={t("plugin.totalPlugins")}>
<Tag color="blue">{stats.total_plugins}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t("plugin.enabledPlugins")}>
<Tag color="success">{stats.enabled_plugins}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t("plugin.disabledPlugins")}>
<Tag color="default">{stats.disabled_plugins}</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
<Button
type="primary"
icon={<FolderOpenOutlined />}
disabled={!canEdit}
onClick={() => setInstallModalVisible(true)}
>
{t("plugin.install")}
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: t("plugin.noPlugins") }}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
</>
),
},
{
key: "builtin",
label: "内置插件",
children: <BuiltinPluginManager canEdit={canEdit} />,
},
]
return (
<div style={{ padding: "24px" }}>
{contextHolder}
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Card size="small" style={{ marginBottom: 16 }}>
<Descriptions size="small" column={3}>
<Descriptions.Item label={t("plugin.totalPlugins")}>
<Tag color="blue">{stats.total_plugins}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t("plugin.enabledPlugins")}>
<Tag color="success">{stats.enabled_plugins}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t("plugin.disabledPlugins")}>
<Tag color="default">{stats.disabled_plugins}</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
<Button
type="primary"
icon={<FolderOpenOutlined />}
disabled={!canEdit}
onClick={() => setInstallModalVisible(true)}
>
{t("plugin.install")}
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: t("plugin.noPlugins") }}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
<Modal
title={t("plugin.install")}
+1 -7
View File
@@ -855,13 +855,7 @@ export const Settings: React.FC<{
<Divider />
{/* 主题设置 - 预留区域 */}
<div style={{ marginBottom: "16px" }}>
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "16px" }}>
{t("settings.theme.title", "主题设置")}
</div>
<ThemeQuickSettings />
</div>
<ThemeQuickSettings />
<Divider />
-475
View File
@@ -1,475 +0,0 @@
import React, { useEffect, useRef, useState } from "react"
import { Menu, Card, Form, Select, Switch, message, Layout, Button, Space } from "antd"
import {
SettingOutlined,
SafetyOutlined,
UserOutlined,
DatabaseOutlined,
FileTextOutlined,
LinkOutlined,
InfoCircleOutlined,
ApiOutlined,
CloseOutlined,
MinusOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
} from "@ant-design/icons"
import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { useTranslation } from "react-i18next"
import { pinyin } from "pinyin-pro"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
import { useResponsive } from "../hooks/useResponsive"
import {
buildSystemFontFamily,
buildSystemFontValue,
SYSTEM_FONT_STACK,
} from "../shared/fontFamily"
import { useConfig } from "../hooks/useConfig"
const { Content } = Layout
type permissionLevel = "admin" | "points" | "view"
interface FontOption {
value: string
label: string
fontFamily: string
searchText: string
}
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
const toPascalCasePinyin = (value: string): string =>
pinyin(value, { toneType: "none" })
.split(/\s+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
.join("")
const formatFontDisplayName = (name: string): string => {
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
}
const buildFontSearchText = (name: string, label: string): string =>
`${name} ${label}`.toLowerCase()
const defaultFontOptions: FontOption[] = [
{
value: "system",
label: "系统默认",
fontFamily: SYSTEM_FONT_STACK,
searchText: "系统默认 system default",
},
]
const mergeFontOptions = (options: FontOption[]): FontOption[] => {
const map = new Map<string, FontOption>()
for (const option of options) {
if (!map.has(option.value)) {
map.set(option.value, option)
}
}
return Array.from(map.values())
}
const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => {
if (!value) return options.find((item) => item.value === "system") || options[0]
return (
options.find((item) => item.value === value) || options.find((item) => item.value === "system")
)
}
const applyFontFamily = (fontFamily: string) => {
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
document.body.style.fontFamily = fontFamily
}
export const SettingsWindow: React.FC<{
permission: permissionLevel
}> = ({ permission }) => {
const { t } = useTranslation()
const breakpoint = useResponsive()
const isMobile = breakpoint === "xs" || breakpoint === "sm"
const [activeTab, setActiveTab] = useState("appearance")
const [currentLanguage, setCurrentLanguage] = useState<AppLanguage>(getCurrentLanguage())
const [fontFamily, setFontFamily, fontLoading] = useConfig("font_family", "system")
const [windowZoom, setWindowZoom] = useConfig("window_zoom", 1.0)
const [searchKeyboardLayout, setSearchKeyboardLayout] = useConfig(
"search_keyboard_layout",
"qwerty26" as any
)
const [disableSearchKeyboard, setDisableSearchKeyboard] = useConfig(
"disable_search_keyboard",
false
)
const [fontOptions, setFontOptions] = useState<FontOption[]>(defaultFontOptions)
const [isLoadingFonts, setIsLoadingFonts] = useState(false)
const fontOptionsRef = useRef<FontOption[]>(defaultFontOptions)
const canAdmin = permission === "admin"
const [messageApi, contextHolder] = message.useMessage()
// 窗口控制状态
const [isMaximized, setIsMaximized] = useState(false)
// 窗口控制函数
const handleMinimize = async () => {
const api = (window as any).api
if (api?.windowMinimize) {
await api.windowMinimize()
}
}
const handleMaximize = async () => {
const api = (window as any).api
if (api?.windowMaximize) {
const result = await api.windowMaximize()
setIsMaximized(result)
}
}
const handleClose = async () => {
const api = (window as any).api
if (api?.closeSettingsWindow) {
await api.closeSettingsWindow()
}
}
// 监听最大化状态变化
useEffect(() => {
const api = (window as any).api
if (api?.onWindowMaximizedChanged) {
api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
}
}, [])
const loadSystemFonts = async (selectedFontValue?: string) => {
setIsLoadingFonts(true)
const applySelectedFont = (options: FontOption[]) => {
const current = findFontOption(options, selectedFontValue || fontFamily)
if (current) applyFontFamily(current.fontFamily)
}
const api = (window as any).api
if (!api?.getSystemFonts) {
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
setIsLoadingFonts(false)
return
}
try {
const res = await api.getSystemFonts()
if (res.success && Array.isArray(res.data)) {
const remoteOptions = res.data
.map((name: string) => String(name || "").trim())
.filter((name: string) => Boolean(name))
.map((name: string) => {
const label = formatFontDisplayName(name)
return {
value: buildSystemFontValue(name),
label,
fontFamily: buildSystemFontFamily(name),
searchText: buildFontSearchText(name, label),
} satisfies FontOption
})
.sort((a: FontOption, b: FontOption) =>
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
)
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
setFontOptions(mergedOptions)
applySelectedFont(mergedOptions)
} else {
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
}
} catch (error) {
console.error("Failed to load system fonts:", error)
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
} finally {
setIsLoadingFonts(false)
}
}
useEffect(() => {
fontOptionsRef.current = fontOptions
}, [fontOptions])
useEffect(() => {
loadSystemFonts(fontFamily)
}, [])
const menuItems = [
{
key: "appearance",
icon: <SettingOutlined />,
label: t("settings.tabs.appearance"),
},
{
key: "security",
icon: <SafetyOutlined />,
label: t("settings.tabs.security"),
},
{
key: "account",
icon: <UserOutlined />,
label: t("settings.tabs.account"),
},
{
key: "database",
icon: <DatabaseOutlined />,
label: t("settings.database.title"),
},
{
key: "data",
icon: <FileTextOutlined />,
label: t("settings.data.title"),
},
{
key: "url",
icon: <LinkOutlined />,
label: t("settings.url.title"),
},
{
key: "about",
icon: <InfoCircleOutlined />,
label: t("settings.about.title"),
},
{
key: "api",
icon: <ApiOutlined />,
label: t("settings.mcp.title"),
},
]
const renderContent = () => {
switch (activeTab) {
case "appearance":
return (
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<Form layout="vertical">
<Form.Item label={t("settings.language")}>
<Select
value={currentLanguage}
onChange={async (v: AppLanguage) => {
await changeLanguage(v)
setCurrentLanguage(v)
messageApi.success(t("common.success"))
}}
style={{ width: "320px" }}
options={languageOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
/>
</Form.Item>
<ThemeQuickSettings />
<Form.Item label={t("settings.fontFamily")}>
<Select
value={fontFamily}
onChange={async (v) => {
const fontOpt = findFontOption(fontOptions, String(v))
if (!fontOpt) return
applyFontFamily(fontOpt.fontFamily)
await setFontFamily(String(v))
messageApi.success(t("settings.general.saved"))
}}
style={{ width: "320px" }}
loading={isLoadingFonts || fontLoading}
options={fontOptions.map((opt) => ({
value: opt.value,
label: opt.label,
searchText: opt.searchText,
}))}
showSearch
filterOption={(input, option) =>
String(option?.searchText ?? option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</Form.Item>
{!isMobile && (
<>
<Form.Item label={t("settings.searchKeyboard.title")}>
<Select
value={searchKeyboardLayout}
onChange={async (v) => {
await setSearchKeyboardLayout(String(v) as any)
messageApi.success(t("settings.general.saved"))
}}
style={{ width: "320px" }}
disabled={!canAdmin}
options={[
{ value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") },
{ value: "t9", label: t("settings.searchKeyboard.options.t9") },
]}
/>
</Form.Item>
<Form.Item label={t("settings.searchKeyboard.disableToggle")}>
<Switch
checked={disableSearchKeyboard}
onChange={async (checked) => {
await setDisableSearchKeyboard(checked)
messageApi.success(t("settings.general.saved"))
}}
disabled={!canAdmin}
/>
</Form.Item>
</>
)}
<Form.Item label={t("settings.interfaceZoom")}>
<Select
value={String(windowZoom)}
onChange={async (v) => {
await setWindowZoom(Number(v))
messageApi.success(t("settings.general.saved"))
}}
style={{ width: "320px" }}
disabled={!canAdmin}
options={[
{ value: "0.7", label: t("settings.zoomOptions.small70") },
{ value: "0.8", label: "80%" },
{ value: "0.9", label: "90%" },
{ value: "1.0", label: t("settings.zoomOptions.default100") },
{ value: "1.1", label: "110%" },
{ value: "1.2", label: "120%" },
{ value: "1.3", label: "130%" },
{ value: "1.5", label: t("settings.zoomOptions.large150") },
]}
/>
</Form.Item>
</Form>
</Card>
)
default:
return (
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div
style={{ textAlign: "center", padding: "40px", color: "var(--ss-text-secondary)" }}
>
{t("common.comingSoon")}
</div>
</Card>
)
}
}
return (
<Layout style={{ minHeight: "100vh", background: "var(--ss-bg)" }}>
{contextHolder}
{!isMobile && (
<Layout.Sider
width={240}
style={{
background: "var(--ss-card-bg)",
borderRight: "1px solid var(--ss-border)",
}}
>
<div style={{ padding: "16px", color: "var(--ss-text-main)", fontWeight: 600 }}>
{t("settings.title")}
</div>
<Menu
mode="inline"
selectedKeys={[activeTab]}
items={menuItems}
onClick={({ key }) => setActiveTab(key)}
style={{ borderRight: 0 }}
/>
</Layout.Sider>
)}
<Layout>
{/* 窗口标题栏 */}
<div
data-tauri-drag-region
style={{
height: "40px",
background: "var(--ss-card-bg)",
borderBottom: "1px solid var(--ss-border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 16px",
userSelect: "none",
// @ts-ignore - Tauri specific property
WebkitAppRegion: "drag",
}}
>
<div style={{ fontWeight: 500, color: "var(--ss-text-main)" }}>{t("settings.title")}</div>
<Space
size={4}
style={{
// @ts-ignore - Tauri specific property
WebkitAppRegion: "no-drag",
}}
>
<Button type="text" size="small" icon={<MinusOutlined />} onClick={handleMinimize} />
<Button
type="text"
size="small"
icon={isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={handleMaximize}
/>
<Button
type="text"
size="small"
danger
icon={<CloseOutlined />}
onClick={handleClose}
/>
</Space>
</div>
<Content style={{ padding: "24px", overflow: "auto" }}>
<div style={{ maxWidth: "900px", margin: "0 auto" }}>{renderContent()}</div>
</Content>
</Layout>
{/* Mobile bottom navigation */}
{isMobile && (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "var(--ss-card-bg)",
borderTop: "1px solid var(--ss-border)",
display: "flex",
justifyContent: "space-around",
padding: "8px 0",
}}
>
{menuItems.map((item) => (
<div
key={item.key}
onClick={() => setActiveTab(item.key)}
style={{
textAlign: "center",
color:
activeTab === item.key ? "var(--ant-color-primary)" : "var(--ss-text-secondary)",
cursor: "pointer",
}}
>
{item.icon}
<div style={{ fontSize: "12px", marginTop: "4px" }}>{item.label}</div>
</div>
))}
</div>
)}
</Layout>
)
}
+60 -121
View File
@@ -16,7 +16,6 @@ import {
import { useState, useEffect } from "react"
import { useTranslation } from "react-i18next"
import appLogo from "../assets/logoHD.svg"
import { usePluginEnabled } from "../hooks/useBuiltinPlugins"
const { Sider } = Layout
@@ -36,25 +35,6 @@ interface DbStatus {
error?: string
}
// 插件菜单项配置
const PLUGIN_MENU_ITEMS: Record<
string,
{ icon: React.ReactElement; labelKey: string; requiresAdmin: boolean }
> = {
"auto-score": { icon: <SyncOutlined />, labelKey: "sidebar.autoScore", requiresAdmin: true },
boards: { icon: <ApartmentOutlined />, labelKey: "sidebar.boards", requiresAdmin: false },
settlements: {
icon: <FileTextOutlined />,
labelKey: "sidebar.settlements",
requiresAdmin: false,
},
"reward-settings": {
icon: <AppstoreAddOutlined />,
labelKey: "sidebar.rewardSettings",
requiresAdmin: true,
},
}
export function Sidebar({
activeMenu,
permission,
@@ -69,12 +49,6 @@ export function Sidebar({
const [syncLoading, setSyncLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
// 检查插件启用状态
const autoScoreEnabled = usePluginEnabled("auto-score")
const boardsEnabled = usePluginEnabled("boards")
const settlementsEnabled = usePluginEnabled("settlements")
const rewardSettingsEnabled = usePluginEnabled("reward-settings")
useEffect(() => {
loadDbStatus()
const handleStatusChange = () => {
@@ -173,95 +147,70 @@ export function Sidebar({
}
}
// 构建菜单项
const buildMenuItems = () => {
const items = [
{
key: "home",
icon: <HomeOutlined />,
label: t("sidebar.home"),
},
{
key: "students",
icon: <UserOutlined />,
label: t("sidebar.students"),
disabled: permission !== "admin",
},
{
key: "score",
icon: <HistoryOutlined />,
label: t("sidebar.score"),
},
]
// 根据插件状态添加菜单项
if (autoScoreEnabled) {
items.push({
key: "auto-score",
icon: PLUGIN_MENU_ITEMS["auto-score"].icon,
label: t(PLUGIN_MENU_ITEMS["auto-score"].labelKey),
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["auto-score"].requiresAdmin,
})
}
if (rewardSettingsEnabled) {
items.push({
key: "reward-settings",
icon: PLUGIN_MENU_ITEMS["reward-settings"].icon,
label: t(PLUGIN_MENU_ITEMS["reward-settings"].labelKey),
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["reward-settings"].requiresAdmin,
})
}
if (boardsEnabled) {
items.push({
key: "boards",
icon: PLUGIN_MENU_ITEMS["boards"].icon,
label: t(PLUGIN_MENU_ITEMS["boards"].labelKey),
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["boards"].requiresAdmin,
})
}
items.push({
const menuItems = [
{
key: "home",
icon: <HomeOutlined />,
label: t("sidebar.home"),
},
{
key: "students",
icon: <UserOutlined />,
label: t("sidebar.students"),
disabled: permission !== "admin",
},
{
key: "score",
icon: <HistoryOutlined />,
label: t("sidebar.score"),
},
{
key: "auto-score",
icon: <SyncOutlined />,
label: t("sidebar.autoScore"),
disabled: permission !== "admin",
},
{
key: "reward-settings",
icon: <AppstoreAddOutlined />,
label: t("sidebar.rewardSettings"),
disabled: permission !== "admin",
},
{
key: "boards",
icon: <ApartmentOutlined />,
label: t("sidebar.boards"),
},
{
key: "leaderboard",
icon: <UnorderedListOutlined />,
label: t("sidebar.leaderboard"),
})
},
{
key: "settlements",
icon: <FileTextOutlined />,
label: t("sidebar.settlements"),
},
{
key: "reasons",
icon: <UnorderedListOutlined />,
label: t("sidebar.reasons"),
disabled: permission !== "admin",
},
{
key: "plugins",
icon: <CrownOutlined />,
label: t("sidebar.plugins"),
disabled: permission !== "admin",
},
{
key: "settings",
icon: <SettingOutlined />,
label: t("sidebar.settings"),
disabled: permission !== "admin",
},
]
if (settlementsEnabled) {
items.push({
key: "settlements",
icon: PLUGIN_MENU_ITEMS["settlements"].icon,
label: t(PLUGIN_MENU_ITEMS["settlements"].labelKey),
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["settlements"].requiresAdmin,
})
}
items.push(
{
key: "reasons",
icon: <UnorderedListOutlined />,
label: t("sidebar.reasons"),
disabled: permission !== "admin",
},
{
key: "plugins",
icon: <CrownOutlined />,
label: t("sidebar.plugins"),
disabled: permission !== "admin",
},
{
key: "settings",
icon: <SettingOutlined />,
label: t("sidebar.settings"),
disabled: permission !== "admin",
}
)
return items
}
const menuItems = buildMenuItems()
const showFloatingPanel = floatingExpand && collapsed && floatingExpanded
const renderSidebarBody = (isCollapsedView: boolean, hideMenu = false) => (
@@ -328,16 +277,6 @@ export function Sidebar({
inlineCollapsed={isCollapsedView}
selectedKeys={[activeMenu]}
onClick={({ key }) => {
if (key === "settings") {
// 打开设置窗口
const api = (window as any).api
if (api?.openSettingsWindow) {
api.openSettingsWindow().catch((err: any) => {
console.error("Failed to open settings window:", err)
})
}
return
}
onMenuChange(key)
if (floatingExpand && collapsed) {
onFloatingExpandedChange(false)
-183
View File
@@ -1,183 +0,0 @@
import type {
ConfigureHostDelegate,
HostBuilderContext,
HostExposure,
HostedService,
PluginHostApplicationContext,
PluginMiddleware,
ServiceToken,
} from "./types"
import { ServiceProvider } from "./serviceCollection"
// 插件Host应用类,管理应用的生命周期和托管服务
export class PluginHostApplication {
private hostedInstances: HostedService[] = [] // 已启动的托管服务实例
private started = false // 是否已启动
constructor(
private readonly context: HostBuilderContext, // Host构建器上下文
private readonly provider: ServiceProvider, // 服务提供者
private readonly configureDelegates: ConfigureHostDelegate[], // 配置委托
private readonly middleware: PluginMiddleware[], // 中间件
private readonly hostedTokens: ServiceToken<HostedService>[] // 托管服务令牌
) {}
// 包装应用,添加服务暴露功能
withExposures(exposures: HostExposure[]): PluginHostApplicationWithExposures {
return new PluginHostApplicationWithExposures(this, exposures)
}
// 启动应用
async start(): Promise<void> {
if (this.started) return
const appCtx = this.createApplicationContext() // 创建应用上下文
// 执行所有配置委托
for (const configure of this.configureDelegates) {
await configure(this.context, appCtx)
}
// 通过中间件管道执行终端逻辑(启动托管服务)
await this.dispatch(0, appCtx, async () => {
await this.bootstrapHostedServices()
})
this.started = true
await this.context.lifetime.notifyStarted() // 通知生命周期已启动
}
// 停止应用
async stop(): Promise<void> {
if (!this.started) return
await this.context.lifetime.notifyStopping() // 通知正在停止
await this.disposeHostedServices() // 停止托管服务
await this.context.lifetime.notifyStopped() // 通知已停止
this.started = false
}
// 释放资源
async dispose(): Promise<void> {
await this.stop()
await this.provider.dispose() // 释放服务提供者
}
// 获取服务提供者
get services(): ServiceProvider {
return this.provider
}
// 获取Host上下文
get hostContext(): HostBuilderContext {
return this.context
}
// 创建应用上下文
private createApplicationContext(): PluginHostApplicationContext {
return {
ctx: this.context.ctx,
services: this.provider,
host: this.context,
}
}
// 启动所有托管服务
private async bootstrapHostedServices() {
for (const token of this.hostedTokens) {
const service = this.provider.get(token) // 从提供者获取服务实例
this.hostedInstances.push(service)
if (typeof service.start === "function") {
await service.start() // 调用启动方法
}
}
}
// 停止所有托管服务
private async disposeHostedServices() {
while (this.hostedInstances.length) {
const service = this.hostedInstances.pop()
if (!service) continue
if (typeof service.stop === "function") {
await service.stop() // 调用停止方法
}
}
}
// 执行中间件管道
private async dispatch(
index: number,
appCtx: PluginHostApplicationContext,
terminal: () => Promise<void>
) {
const middleware = this.middleware[index]
if (!middleware) {
await terminal() // 执行终端逻辑
return
}
// 调用中间件,传入下一个中间件的执行函数
await middleware(appCtx, () => this.dispatch(index + 1, appCtx, terminal))
}
}
// 带服务暴露的Host应用类
export class PluginHostApplicationWithExposures {
private readonly exposureDisposers: Array<() => void> = [] // 暴露服务的清理函数
constructor(
private readonly app: PluginHostApplication,
private readonly exposures: HostExposure[]
) {}
// 启动应用,包括注册暴露服务
async start(): Promise<void> {
try {
await this.registerExposures() // 先注册暴露服务
await this.app.start() // 再启动应用
} catch (error) {
await this.disposeExposures() // 出错时清理暴露服务
throw error
}
}
// 停止应用,包括清理暴露服务
async stop(): Promise<void> {
await this.app.stop()
await this.disposeExposures()
}
// 释放资源
async dispose(): Promise<void> {
await this.app.dispose()
await this.disposeExposures()
}
// 获取服务提供者
get services() {
return this.app.services
}
// 获取Host上下文
get hostContext() {
return this.app.hostContext
}
// 注册暴露的服务到插件上下文
private async registerExposures() {
const ctx = this.app.hostContext.ctx
if (!ctx.services) return
for (const exposure of this.exposures) {
const value = exposure.resolver(this.app.services) // 解析服务值
const disposer = ctx.services.provide(exposure.name, value) // 注册到服务API
this.exposureDisposers.push(disposer)
}
}
// 清理暴露的服务
private async disposeExposures() {
while (this.exposureDisposers.length) {
const dispose = this.exposureDisposers.pop()
if (!dispose) continue
await dispose() // 调用清理函数
}
}
}
-164
View File
@@ -1,164 +0,0 @@
import { ServiceCollection, ServiceProvider } from "./serviceCollection"
import { PluginHostApplication, PluginHostApplicationWithExposures } from "./hostApplication"
import {
type ConfigureHostDelegate,
type ConfigureServicesDelegate,
type HostBuilderContext,
type HostBuilderSettings,
type HostExposure,
type HostExposureResolver,
type HostedService,
type PluginMiddleware,
type PluginRuntimeContext,
type ServiceToken,
} from "./types"
// ExamAware Host构建器,类似.NET的HostBuilder,用于配置插件的服务和生命周期
export class ExamAwareHostBuilder {
private readonly serviceCollection: ServiceCollection // 服务集合,管理所有注册的服务
private readonly configureServicesDelegates: ConfigureServicesDelegate[] = [] // 配置服务的委托列表
private readonly configureDelegates: ConfigureHostDelegate[] = [] // 配置应用的委托列表
private readonly middleware: PluginMiddleware[] = [] // 中间件列表
private readonly hostedServices: ServiceToken<HostedService>[] = [] // 托管服务的令牌列表
private readonly exposures: HostExposure[] = [] // 要暴露的服务列表
private readonly builderContext: HostBuilderContext // 构建器上下文
constructor(runtimeCtx: PluginRuntimeContext, settings: HostBuilderSettings = {}) {
this.serviceCollection = new ServiceCollection(runtimeCtx)
this.builderContext = {
ctx: runtimeCtx,
environmentName: settings.environment ?? process.env.EXAMAWARE_ENV ?? "Production",
properties: new Map(Object.entries(settings.properties ?? {})),
lifetime: this.serviceCollection.getLifetime(), // 获取应用生命周期管理器
}
}
// 获取构建器上下文
get context(): HostBuilderContext {
return this.builderContext
}
// 添加服务配置委托
configureServices(callback: ConfigureServicesDelegate): this {
this.configureServicesDelegates.push(callback)
return this
}
// 添加应用配置委托
configure(callback: ConfigureHostDelegate): this {
this.configureDelegates.push(callback)
return this
}
// 添加中间件
use(middleware: PluginMiddleware): this {
this.middleware.push(middleware)
return this
}
// 添加托管服务
addHostedService(token: ServiceToken<HostedService>): this {
this.hostedServices.push(token)
return this
}
// 暴露服务到插件上下文
exposeHostService(name: string, resolver: HostExposureResolver): this {
const normalized = this.normalizeResolver(resolver)
if (normalized) {
this.exposures.push({ name, resolver: normalized })
}
return this
}
// 构建Host应用
async build(): Promise<PluginHost> {
// 执行所有服务配置委托
for (const configure of this.configureServicesDelegates) {
await configure(this.builderContext, this.serviceCollection)
}
const provider = this.serviceCollection.buildServiceProvider() // 构建服务提供者
const application = new PluginHostApplication(
this.builderContext,
provider,
this.configureDelegates,
this.middleware,
this.hostedServices
)
return new PluginHost(application.withExposures(this.exposures)) // 返回包装了暴露服务的Host
}
// 标准化暴露解析器
private normalizeResolver(
resolver: HostExposureResolver
): ((provider: ServiceProvider) => unknown) | null {
if (resolver.token) {
return (provider) => provider.get(resolver.token as ServiceToken) // 通过令牌获取服务
}
if (resolver.factory) {
return resolver.factory // 使用工厂函数
}
return null
}
}
// 插件Host类,管理应用的启动和停止
export class PluginHost {
constructor(private readonly app: PluginHostApplicationWithExposures) {}
// 获取服务提供者
get services() {
return this.app.services
}
// 获取Host上下文
get hostContext() {
return this.app.hostContext
}
// 启动应用
async start() {
await this.app.start()
}
// 停止应用
async stop() {
await this.app.stop()
}
// 释放资源
async dispose() {
await this.app.dispose()
}
// 运行应用,返回停止函数
async run() {
await this.start()
return async () => {
await this.dispose()
}
}
}
// 创建Host构建器的工厂函数
export function createPluginHostBuilder(ctx: PluginRuntimeContext, settings?: HostBuilderSettings) {
return new ExamAwareHostBuilder(ctx, settings)
}
// Host工具类,提供创建构建器的静态方法
export const Host = {
createApplicationBuilder: createPluginHostBuilder,
}
// 定义插件的辅助函数,使用Host构建器配置插件
export function defineExamAwarePlugin(
setup: (builder: ExamAwareHostBuilder) => Promise<void> | void
) {
return async function examAwarePlugin(ctx: PluginRuntimeContext) {
const builder = Host.createApplicationBuilder(ctx)
await setup(builder)
const host = await builder.build()
return host.run() // 返回运行函数,插件加载时调用
}
}
-30
View File
@@ -1,30 +0,0 @@
export {
ExamAwareHostBuilder,
PluginHost,
createPluginHostBuilder,
Host,
defineExamAwarePlugin,
} from "./hostBuilder"
export { ServiceCollection, ServiceProvider } from "./serviceCollection"
export {
PluginContextToken,
PluginLoggerToken,
PluginSettingsToken,
DesktopApiToken,
HostApplicationLifetimeToken,
} from "./tokens"
export type {
PluginRuntimeContext,
PluginLogger,
PluginSettingsAPI,
ServiceAPI,
HostedService,
PluginMiddleware,
HostBuilderSettings,
HostBuilderContext,
PluginHostApplicationLifetime,
ConfigureServicesDelegate,
ConfigureHostDelegate,
PluginHostApplicationContext,
ServiceToken,
} from "./types"
-61
View File
@@ -1,61 +0,0 @@
import type { Awaitable, Disposer, PluginHostApplicationLifetime } from "./types"
type LoggerLike = { error: (...args: any[]) => void }
// 默认的应用生命周期实现,管理启动/停止事件
export class DefaultHostApplicationLifetime implements PluginHostApplicationLifetime {
constructor(private readonly logger: LoggerLike = console) {}
private readonly started = new Set<() => Awaitable<void>>() // 启动事件处理器
private readonly stopping = new Set<() => Awaitable<void>>() // 停止事件处理器
private readonly stopped = new Set<() => Awaitable<void>>() // 已停止事件处理器
// 注册启动事件处理器
onStarted(handler: () => Awaitable<void>): Disposer {
this.started.add(handler)
return () => {
this.started.delete(handler)
} // 返回清理函数
}
// 注册停止事件处理器
onStopping(handler: () => Awaitable<void>): Disposer {
this.stopping.add(handler)
return () => {
this.stopping.delete(handler)
}
}
// 注册已停止事件处理器
onStopped(handler: () => Awaitable<void>): Disposer {
this.stopped.add(handler)
return () => {
this.stopped.delete(handler)
}
}
// 通知所有启动事件处理器
async notifyStarted() {
await this.dispatch(this.started)
}
// 通知所有停止事件处理器
async notifyStopping() {
await this.dispatch(this.stopping)
}
// 通知所有已停止事件处理器
async notifyStopped() {
await this.dispatch(this.stopped)
}
// 执行事件处理器列表
private async dispatch(targets: Set<() => Awaitable<void>>) {
for (const handler of Array.from(targets)) {
try {
await handler()
} catch (error) {
this.logger.error("[PluginHostLifetime] handler failed", error as Error) // 记录错误但不中断
}
}
}
}
-237
View File
@@ -1,237 +0,0 @@
import {
DesktopApiToken,
HostApplicationLifetimeToken,
PluginContextToken,
PluginLoggerToken,
PluginSettingsToken,
} from "./tokens"
import { DefaultHostApplicationLifetime } from "./lifetime"
import type {
Disposer,
InjectableClass,
PluginRuntimeContext,
ServiceDescriptor,
ServiceFactory,
ServiceFactoryOrValue,
ServiceLifetime,
ServiceToken,
} from "./types"
// 检查值是否为构造函数
function isConstructor<T>(value: ServiceFactoryOrValue<T>): value is InjectableClass<T> {
return (
typeof value === "function" &&
!!(value as any).prototype &&
(value as any).prototype.constructor === value
)
}
// 服务集合类,管理所有注册的服务描述符
export class ServiceCollection {
private readonly descriptors = new Map<ServiceToken, ServiceDescriptor>() // 服务描述符映射
private readonly hostLifetime: DefaultHostApplicationLifetime // 应用生命周期管理器
constructor(private readonly ctx: PluginRuntimeContext) {
this.hostLifetime = new DefaultHostApplicationLifetime((ctx as any).logger ?? console)
// 预注册常用服务
this.addSingleton(PluginContextToken, () => ctx)
this.addSingleton(PluginLoggerToken, () => ctx.logger)
this.addSingleton(PluginSettingsToken, () => ctx.settings)
this.addSingleton(DesktopApiToken, () => ctx.desktopApi)
this.addSingleton(HostApplicationLifetimeToken, () => this.hostLifetime)
}
// 获取生命周期管理器
getLifetime() {
return this.hostLifetime
}
// 添加单例服务
addSingleton<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "singleton", impl)
}
// 添加作用域服务
addScoped<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "scoped", impl)
}
// 添加瞬时服务
addTransient<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
return this.register(token, "transient", impl)
}
// 尝试添加单例服务(如果不存在)
tryAddSingleton<T>(token: ServiceToken<T>, impl: ServiceFactoryOrValue<T>): this {
if (!this.descriptors.has(token)) {
this.addSingleton(token, impl)
}
return this
}
// 检查服务是否已注册
has(token: ServiceToken): boolean {
return this.descriptors.has(token)
}
// 清空所有服务
clear(): void {
this.descriptors.clear()
}
// 构建服务提供者
buildServiceProvider(): ServiceProvider {
return new ServiceProvider(this.ctx, new Map(this.descriptors))
}
// 注册服务
private register<T>(
token: ServiceToken<T>,
lifetime: ServiceLifetime,
impl: ServiceFactoryOrValue<T>
): this {
const descriptor: ServiceDescriptor = {
token,
lifetime,
factory: this.normalizeFactory(impl), // 标准化工厂函数
}
this.descriptors.set(token, descriptor)
return this
}
// 标准化工厂函数
private normalizeFactory<T>(impl: ServiceFactoryOrValue<T>): ServiceFactory<T> {
if (isConstructor(impl)) {
return (provider) => this.instantiateClass(impl, provider) // 构造函数,实例化类
}
if (typeof impl === "function") {
return impl as ServiceFactory<T> // 已经是工厂函数
}
return () => impl // 直接值,返回常量
}
// 实例化类,注入依赖
private instantiateClass<T>(Ctor: InjectableClass<T>, provider: ServiceProvider): T {
const deps = [...(Ctor.inject ?? [])].map((token) => provider.get(token)) // 获取依赖
return new Ctor(...deps) // 构造实例
}
}
// 服务提供者类,负责解析和提供服务实例
export class ServiceProvider {
private readonly singletonCache: Map<ServiceToken, unknown> // 单例缓存
private readonly scopedCache = new Map<ServiceToken, unknown>() // 作用域缓存
private readonly singletonCleanup: Disposer[] // 单例清理函数
private readonly scopedCleanup: Disposer[] = [] // 作用域清理函数
private readonly root: ServiceProvider | null // 根提供者
constructor(
private readonly ctx: PluginRuntimeContext,
private readonly descriptors: Map<ServiceToken, ServiceDescriptor>,
root: ServiceProvider | null = null,
private readonly isScope = false // 是否为作用域提供者
) {
this.root = root
this.singletonCache = root ? root.singletonCache : new Map() // 共享单例缓存
this.singletonCleanup = root ? root.singletonCleanup : []
}
// 获取服务实例
get<T>(token: ServiceToken<T>): T {
const descriptor = this.descriptors.get(token) as ServiceDescriptor<T> | undefined
if (!descriptor) {
const hostValue = this.resolveFromHost<T>(token) // 尝试从宿主获取
if (hostValue !== undefined) return hostValue
throw new Error(`Service not registered for token: ${token.toString?.() ?? String(token)}`)
}
switch (descriptor.lifetime) {
case "singleton":
return this.resolveSingleton(descriptor)
case "scoped":
return this.resolveScoped(descriptor)
case "transient":
default:
return this.instantiate(descriptor) // 每次都新实例
}
}
// 创建作用域提供者
createScope(): ServiceProvider {
return new ServiceProvider(this.ctx, this.descriptors, this.root ?? this, true)
}
// 释放资源
async dispose(): Promise<void> {
await this.flushCleanup(this.scopedCleanup) // 先清理作用域
if (!this.isScope) {
await this.flushCleanup(this.singletonCleanup) // 再清理单例
this.singletonCache.clear()
}
this.scopedCache.clear()
}
// 解析单例服务
private resolveSingleton<T>(descriptor: ServiceDescriptor<T>): T {
const cacheOwner = this.root ?? this
if (cacheOwner.singletonCache.has(descriptor.token)) {
return cacheOwner.singletonCache.get(descriptor.token) as T
}
const instance = this.instantiate(descriptor)
cacheOwner.singletonCache.set(descriptor.token, instance)
const disposer = this.extractDisposer(instance) // 提取清理函数
if (disposer) cacheOwner.singletonCleanup.push(disposer)
return instance
}
// 解析作用域服务
private resolveScoped<T>(descriptor: ServiceDescriptor<T>): T {
if (this.scopedCache.has(descriptor.token)) {
return this.scopedCache.get(descriptor.token) as T
}
const instance = this.instantiate(descriptor)
this.scopedCache.set(descriptor.token, instance)
const disposer = this.extractDisposer(instance)
if (disposer) this.scopedCleanup.push(disposer)
return instance
}
// 实例化服务
private instantiate<T>(descriptor: ServiceDescriptor<T>): T {
return descriptor.factory(this)
}
// 从宿主服务API解析
private resolveFromHost<T>(token: ServiceToken<T>): T | undefined {
if (typeof token === "string" && this.ctx.services?.has?.(token)) {
return this.ctx.services.inject<T>(token)
}
return undefined
}
// 提取实例的清理函数
private extractDisposer(instance: unknown): Disposer | null {
if (!instance) return null
if (typeof (instance as any).dispose === "function") {
return () => (instance as any).dispose()
}
if (typeof (instance as any).destroy === "function") {
return () => (instance as any).destroy()
}
if (typeof (instance as any).stop === "function") {
return () => (instance as any).stop()
}
return null
}
// 执行清理函数列表
private async flushCleanup(cleanup: Disposer[]) {
while (cleanup.length) {
const disposer = cleanup.pop()
if (!disposer) continue
await disposer()
}
}
}
-17
View File
@@ -1,17 +0,0 @@
// 服务令牌定义,用于依赖注入
// 使用Symbol确保唯一性,避免字符串冲突
// 插件运行时上下文令牌
export const PluginContextToken = Symbol.for("examaware.hosting.ctx")
// 插件日志器令牌
export const PluginLoggerToken = Symbol.for("examaware.hosting.logger")
// 插件设置API令牌
export const PluginSettingsToken = Symbol.for("examaware.hosting.settings")
// Desktop API令牌
export const DesktopApiToken = Symbol.for("examaware.hosting.desktopApi")
// 应用生命周期管理器令牌
export const HostApplicationLifetimeToken = Symbol.for("examaware.hosting.lifetime")
-150
View File
@@ -1,150 +0,0 @@
import type { ServiceProvideOptions, ServiceWatcherMeta } from "../../shared/services/registry"
import type { ServiceCollection, ServiceProvider } from "./serviceCollection"
// 基础类型定义
export type Awaitable<T> = T | Promise<T> // 可以是同步值或Promise
export type Disposer = () => Awaitable<void> // 清理函数,返回void或Promise<void>
// 服务令牌,可以是字符串、符号或构造函数
export type ServiceToken<T = unknown> = string | symbol | (new (...args: any[]) => T)
// 可注入的类,带可选的inject属性声明依赖
export interface InjectableClass<T = unknown> {
new (...args: any[]): T
inject?: readonly ServiceToken[] // 依赖的令牌列表
}
// 服务工厂函数,从provider获取实例
export type ServiceFactory<T> = (provider: ServiceProvider) => T
// 服务实现,可以是工厂函数、构造函数或直接值
export type ServiceFactoryOrValue<T> = ServiceFactory<T> | InjectableClass<T> | T
// 服务生命周期:单例、作用域内单例、每次都新实例
export type ServiceLifetime = "singleton" | "scoped" | "transient"
// 服务描述符,定义如何创建服务
export interface ServiceDescriptor<T = unknown> {
token: ServiceToken<T>
lifetime: ServiceLifetime
factory: ServiceFactory<T> // 创建实例的工厂函数
}
// 托管服务接口,有启动和停止方法
export interface HostedService {
start(): Awaitable<void>
stop(): Awaitable<void>
}
// Host构建器设置
export interface HostBuilderSettings {
environment?: string // 环境名,如'development'
properties?: Record<string, unknown> // 额外属性
}
// Host构建器上下文,包含运行时上下文和配置
export interface HostBuilderContext {
ctx: PluginRuntimeContext // 插件运行时上下文
environmentName: string // 当前环境
properties: Map<string | symbol, unknown> // 属性映射
lifetime: PluginHostApplicationLifetime // 应用生命周期管理
}
// 插件应用上下文,包含服务提供者和上下文
export interface PluginHostApplicationContext {
ctx: PluginRuntimeContext
services: ServiceProvider
host: HostBuilderContext
}
// 配置服务委托,在构建时注册服务
export type ConfigureServicesDelegate = (
context: HostBuilderContext,
services: ServiceCollection
) => Awaitable<void>
// 配置Host委托,在应用启动时执行
export type ConfigureHostDelegate = (
context: HostBuilderContext,
app: PluginHostApplicationContext
) => Awaitable<void>
// 中间件函数,包装应用逻辑
export type PluginMiddleware = (
app: PluginHostApplicationContext,
next: () => Promise<void>
) => Awaitable<void>
// 暴露服务解析器,可以通过令牌或工厂函数
export interface HostExposureResolver<T = unknown> {
token?: ServiceToken<T> // 通过令牌暴露
factory?: (provider: ServiceProvider) => T // 通过工厂函数暴露
}
// 服务API接口,插件用来注册和获取服务
export interface ServiceAPI {
provide: (name: string, value: unknown, options?: ServiceProvideOptions) => Disposer
inject: <T = unknown>(name: string, owner?: string) => T
injectAsync?: <T = unknown>(name: string, owner?: string) => Promise<T>
when?: <T = unknown>(
name: string,
cb: (svc: T, owner: string, meta: ServiceWatcherMeta) => void | (() => void)
) => Disposer
has: (name: string, owner?: string) => boolean
}
// 插件日志接口
export interface PluginLogger {
info: (...args: any[]) => void
warn: (...args: any[]) => void
error: (...args: any[]) => void
debug?: (...args: any[]) => void
}
// 插件设置API,读写配置
export interface PluginSettingsAPI {
all(): Record<string, any> // 获取所有配置
get<T = unknown>(key?: string, def?: T): T // 获取单个配置项
set<T = unknown>(key: string, value: T): Promise<void> // 设置配置项
patch(partial: Record<string, any>): Promise<void> // 批量更新配置
reset(): Promise<void> // 重置配置
onChange(listener: (config: Record<string, any>) => void): Disposer // 监听配置变化
}
// 插件运行时上下文,插件的核心接口
export interface PluginRuntimeContext {
app: "main" | "renderer" // 运行在主进程还是渲染进程
logger: PluginLogger // 日志器
config: Record<string, any> // 插件配置
settings: PluginSettingsAPI // 设置API
effect: (fn: () => void | Disposer | Promise<void | Disposer>) => void // 注册副作用清理
services: ServiceAPI // 服务API
windows?: {
// 窗口操作(主进程)
broadcast: (channel: string, payload?: any) => void
}
ipc?: {
// IPC通信(主进程)
registerChannel: (channel: string, handler: (event: unknown, ...args: any[]) => any) => Disposer
invokeRenderer?: (channel: string, payload?: any) => void
}
desktopApi?: unknown // Desktop API(渲染进程)
}
// 插件应用生命周期接口,管理启动/停止事件
export interface PluginHostApplicationLifetime {
onStarted(handler: () => Awaitable<void>): Disposer // 监听启动事件
onStopping(handler: () => Awaitable<void>): Disposer // 监听停止事件
onStopped(handler: () => Awaitable<void>): Disposer // 监听已停止事件
notifyStarted(): Promise<void> // 触发启动通知
notifyStopping(): Promise<void> // 触发停止通知
notifyStopped(): Promise<void> // 触发已停止通知
}
// 暴露的服务定义
export type HostExposure = {
name: string // 服务名
resolver: (provider: ServiceProvider) => unknown // 解析函数
}
// 重新导出类型,便于使用
export type { ServiceCollection, ServiceProvider }
-109
View File
@@ -1,109 +0,0 @@
import { ServiceCollection, ServiceProvider } from "../copying/hosting/serviceCollection"
import { PluginRuntimeContext, ServiceToken } from "../copying/hosting/types"
import { ConfigService } from "../services/ConfigService"
/**
* SecScore 应用服务注册表
* 统一管理所有应用级服务的注册和访问
*/
export class AppServiceRegistry {
private static instance: AppServiceRegistry
private serviceCollection: ServiceCollection | null = null
private serviceProvider: ServiceProvider | null = null
private initialized = false
private constructor() {}
static getInstance(): AppServiceRegistry {
if (!AppServiceRegistry.instance) {
AppServiceRegistry.instance = new AppServiceRegistry()
}
return AppServiceRegistry.instance
}
/**
* 初始化服务集合
* @param ctx 运行时上下文
*/
initialize(ctx: PluginRuntimeContext): void {
if (this.initialized) return
this.serviceCollection = new ServiceCollection(ctx)
this.registerCoreServices(ctx)
this.serviceProvider = this.serviceCollection.buildServiceProvider()
this.initialized = true
}
/**
* 注册核心服务
*/
private registerCoreServices(ctx: PluginRuntimeContext): void {
if (!this.serviceCollection) return
// 注册配置服务
this.serviceCollection.addSingleton("ConfigService", ConfigService)
// 注册运行时上下文
this.serviceCollection.addSingleton("PluginContext", ctx)
}
/**
* 获取服务提供者
*/
getServiceProvider(): ServiceProvider {
if (!this.serviceProvider) {
throw new Error("Service registry not initialized. Call initialize() first.")
}
return this.serviceProvider
}
/**
* 获取服务实例
*/
getService<T>(token: ServiceToken<T>): T {
return this.getServiceProvider().get(token)
}
/**
* 检查服务是否已注册
*/
hasService(token: ServiceToken): boolean {
if (!this.serviceCollection) return false
return this.serviceCollection.has(token)
}
/**
* 注册服务
*/
registerService<T>(
token: ServiceToken<T>,
impl: any,
lifetime: "singleton" | "scoped" | "transient" = "singleton"
): void {
if (!this.serviceCollection) {
throw new Error("Service collection not initialized")
}
switch (lifetime) {
case "singleton":
this.serviceCollection.addSingleton(token, impl)
break
case "scoped":
this.serviceCollection.addScoped(token, impl)
break
case "transient":
this.serviceCollection.addTransient(token, impl)
break
}
}
/**
* 是否已初始化
*/
isInitialized(): boolean {
return this.initialized
}
}
// 导出单例实例
export const appRegistry = AppServiceRegistry.getInstance()
-251
View File
@@ -1,251 +0,0 @@
import { Context, disposer } from "../shared/kernel"
/**
* 可逆插件系统基础类
* 基于 Koishi 的 Disposable 设计理念
*
* 特性:
* - 可组合性:插件可拆卸
* - 可靠性:资源安全,可追踪
* - 可访问性:热重载支持
*/
export abstract class DisposablePlugin extends Context {
private _pluginName: string
private _pluginDisposables: Set<disposer> = new Set()
private _initialized = false
constructor(name: string) {
super()
this._pluginName = name
}
/**
* 插件名称
*/
get name(): string {
return this._pluginName
}
/**
* 是否已初始化
*/
get initialized(): boolean {
return this._initialized
}
/**
* 注册可逆操作
* @param fn 清理函数
*/
protected registerDisposer(fn: disposer): void {
this._pluginDisposables.add(fn)
this.effect(() => {
this._pluginDisposables.delete(fn)
})
}
/**
* 初始化插件
* 子类应重写此方法实现具体逻辑
*/
async initialize(): Promise<void> {
if (this._initialized) return
try {
await this.onInitialize()
this._initialized = true
this.emit("initialized")
} catch (error) {
this.emit("error", error)
throw error
}
}
/**
* 插件初始化逻辑
* 子类应重写此方法
*/
protected abstract onInitialize(): Promise<void>
/**
* 卸载插件
*/
async dispose(): Promise<void> {
this.emit("disposing")
// 执行所有清理函数
const disposers = [...this._pluginDisposables]
this._pluginDisposables.clear()
for (const disposer of disposers) {
try {
await disposer()
} catch (error) {
this.emit("error", error)
}
}
// 调用父类的 dispose
super.dispose()
this._initialized = false
this.emit("disposed")
}
/**
* 热重载插件
*/
async reload(): Promise<void> {
await this.dispose()
this._pluginDisposables.clear()
await this.initialize()
}
/**
* 监听初始化完成
*/
onInitialized(fn: () => void): void {
this.once("initialized", fn)
}
/**
* 监听卸载开始
*/
onDisposing(fn: () => void): void {
this.once("disposing", fn)
}
/**
* 监听卸载完成
*/
onDisposed(fn: () => void): void {
this.once("disposed", fn)
}
/**
* 监听错误
*/
onError(fn: (error: Error) => void): void {
this.on("error", fn)
}
}
/**
* 插件工厂函数类型
*/
export type PluginFactory<T extends DisposablePlugin = DisposablePlugin> = () => T | Promise<T>
/**
* 创建插件
* @param factory 插件工厂函数
*/
export function createPlugin<T extends DisposablePlugin>(
factory: PluginFactory<T>
): PluginFactory<T> {
return factory
}
/**
* 插件管理器
* 管理所有插件的生命周期
*/
export class PluginManager extends Context {
private plugins: Map<string, DisposablePlugin> = new Map()
/**
* 注册插件
*/
async registerPlugin(plugin: DisposablePlugin): Promise<void> {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin "${plugin.name}" already registered`)
}
this.plugins.set(plugin.name, plugin)
plugin.on("error", (error) => {
this.emit("plugin-error", { plugin: plugin.name, error })
})
try {
await plugin.initialize()
this.emit("plugin-registered", plugin.name)
} catch (error) {
this.plugins.delete(plugin.name)
throw error
}
}
/**
* 卸载插件
*/
async unregisterPlugin(name: string): Promise<void> {
const plugin = this.plugins.get(name)
if (!plugin) return
await plugin.dispose()
this.plugins.delete(name)
this.emit("plugin-unregistered", name)
}
/**
* 获取插件
*/
getPlugin<T extends DisposablePlugin>(name: string): T | undefined {
return this.plugins.get(name) as T
}
/**
* 检查插件是否存在
*/
hasPlugin(name: string): boolean {
return this.plugins.has(name)
}
/**
* 获取所有插件
*/
getAllPlugins(): Map<string, DisposablePlugin> {
return new Map(this.plugins)
}
/**
* 热重载插件
*/
async reloadPlugin(name: string): Promise<void> {
const plugin = this.plugins.get(name)
if (!plugin) throw new Error(`Plugin "${name}" not found`)
await plugin.reload()
this.emit("plugin-reloaded", name)
}
/**
* 监听插件注册
*/
onPluginRegistered(fn: (name: string) => void): void {
this.on("plugin-registered", fn)
}
/**
* 监听插件卸载
*/
onPluginUnregistered(fn: (name: string) => void): void {
this.on("plugin-unregistered", fn)
}
/**
* 监听插件重载
*/
onPluginReloaded(fn: (name: string) => void): void {
this.on("plugin-reloaded", fn)
}
/**
* 监听插件错误
*/
onPluginError(fn: (data: { plugin: string; error: Error }) => void): void {
this.on("plugin-error", fn)
}
}
// 导出全局插件管理器实例
export const globalPluginManager = new PluginManager()
-225
View File
@@ -1,225 +0,0 @@
import { invoke } from "@tauri-apps/api/core"
import { UnlistenFn } from "@tauri-apps/api/event"
export interface WindowConfig {
label: string
title: string
width?: number
height?: number
resizable?: boolean
maximized?: boolean
decorations?: boolean
alwaysOnTop?: boolean
center?: boolean
}
export interface WindowState {
label: string
visible: boolean
maximized: boolean
minimized: boolean
focused: boolean
}
/**
* SecScore 窗口管理器
* 统一管理所有应用窗口
*/
class WindowManagerClass {
private windows: Map<string, WindowConfig> = new Map()
private currentWindow: string | null = null
private unlistenFns: UnlistenFn[] = []
constructor() {
this.setupWindowListeners()
}
/**
* 注册窗口配置
*/
registerWindow(config: WindowConfig): void {
this.windows.set(config.label, config)
}
/**
* 获取窗口配置
*/
getWindowConfig(label: string): WindowConfig | undefined {
return this.windows.get(label)
}
/**
* 设置当前窗口
*/
setCurrentWindow(label: string): void {
this.currentWindow = label
}
/**
* 获取当前窗口标签
*/
getCurrentWindow(): string | null {
return this.currentWindow
}
/**
* 最小化窗口
*/
async minimizeWindow(): Promise<void> {
const api = (window as any).api
if (api?.windowMinimize) {
await api.windowMinimize()
} else {
await invoke("window_minimize")
}
}
/**
* 最大化/还原窗口
*/
async toggleMaximize(): Promise<void> {
const api = (window as any).api
if (api?.windowMaximize) {
const isMaximized = await api.windowIsMaximized()
if (isMaximized) {
// 还原窗口逻辑需要 Rust 端支持
await this.minimizeWindow()
} else {
await api.windowMaximize()
}
} else {
await invoke("window_maximize")
}
}
/**
* 关闭窗口
*/
async closeWindow(): Promise<void> {
const api = (window as any).api
if (api?.windowClose) {
await api.windowClose()
} else {
await invoke("window_close")
}
}
/**
* 检查窗口是否最大化
*/
async isMaximized(): Promise<boolean> {
const api = (window as any).api
if (api?.windowIsMaximized) {
return await api.windowIsMaximized()
}
return await invoke("window_is_maximized")
}
/**
* 开始拖动窗口
*/
async startDragging(): Promise<void> {
const api = (window as any).api
if (api?.startDraggingWindow) {
await api.startDraggingWindow()
}
}
/**
* 切换开发者工具
*/
async toggleDevTools(): Promise<void> {
const api = (window as any).api
if (api?.toggleDevTools) {
await api.toggleDevTools()
} else {
await invoke("toggle_devtools")
}
}
/**
* 调整窗口大小
*/
async resizeWindow(width: number, height: number): Promise<void> {
const api = (window as any).api
if (api?.windowResize) {
await api.windowResize(width, height)
} else {
await invoke("window_resize", { width, height })
}
}
/**
* 设置窗口是否可调整大小
*/
async setResizable(resizable: boolean): Promise<void> {
const api = (window as any).api
if (api?.windowSetResizable) {
await api.windowSetResizable(resizable)
} else {
await invoke("window_set_resizable", { resizable })
}
}
/**
* 监听窗口最大化变化
*/
onWindowMaximizedChanged(callback: (maximized: boolean) => void): void {
const api = (window as any).api
if (api?.onWindowMaximizedChanged) {
api.onWindowMaximizedChanged(callback).then((unlisten: UnlistenFn) => {
this.unlistenFns.push(unlisten)
})
}
}
/**
* 监听导航事件
*/
onNavigate(callback: (route: string) => void): void {
const api = (window as any).api
if (api?.onNavigate) {
api.onNavigate(callback).then((unlisten: UnlistenFn) => {
this.unlistenFns.push(unlisten)
})
}
}
/**
* 设置窗口标题
*/
async setTitle(title: string): Promise<void> {
document.title = title
}
/**
* 获取所有注册窗口
*/
getAllWindows(): Map<string, WindowConfig> {
return new Map(this.windows)
}
/**
* 清理监听器
*/
dispose(): void {
this.unlistenFns.forEach((unlisten) => unlisten())
this.unlistenFns = []
this.windows.clear()
this.currentWindow = null
}
private setupWindowListeners(): void {
// 监听最大化变化
this.onWindowMaximizedChanged((maximized) => {
console.log("Window maximized changed:", maximized)
})
// 监听导航事件
this.onNavigate((route) => {
console.log("Navigate to:", route)
})
}
}
export const WindowManager = new WindowManagerClass()
-60
View File
@@ -1,60 +0,0 @@
/**
* SecScore 依赖注入系统
* 基于 ExamAware 项目的 DI 实现进行适配
* 参照 Koishi 的 Disposable 设计理念
*/
// DI 核心
export {
ExamAwareHostBuilder,
PluginHost,
createPluginHostBuilder,
Host,
defineExamAwarePlugin,
} from "../copying/hosting/hostBuilder"
export { ServiceCollection, ServiceProvider } from "../copying/hosting/serviceCollection"
export {
PluginContextToken,
PluginLoggerToken,
PluginSettingsToken,
DesktopApiToken,
HostApplicationLifetimeToken,
} from "../copying/hosting/tokens"
export type {
PluginRuntimeContext,
PluginLogger,
PluginSettingsAPI,
ServiceAPI,
HostedService,
PluginMiddleware,
HostBuilderSettings,
HostBuilderContext,
PluginHostApplicationLifetime,
ConfigureServicesDelegate,
ConfigureHostDelegate,
PluginHostApplicationContext,
ServiceToken,
} from "../copying/hosting/types"
// 可逆插件系统
export {
DisposablePlugin,
PluginManager,
globalPluginManager,
createPlugin,
type PluginFactory,
} from "./DisposablePlugin"
// 应用服务注册表
export { AppServiceRegistry, appRegistry } from "./AppServiceRegistry"
// 窗口管理器
export { WindowManager } from "./WindowManager"
// 内置插件
export {
WikiPlugin,
NotificationPlugin,
DataExportPlugin,
type WikiPluginConfig,
} from "./plugins/builtin"
-181
View File
@@ -1,181 +0,0 @@
/**
* 内置插件示例:Wiki 插件
* 演示如何使用 DisposablePlugin 创建可逆插件
*/
import { DisposablePlugin } from "../DisposablePlugin"
import { ConfigService } from "../../services/ConfigService"
export interface WikiPluginConfig {
enabled: boolean
wikiUrl?: string
cacheEnabled: boolean
}
export class WikiPlugin extends DisposablePlugin {
private config: WikiPluginConfig = {
enabled: true,
wikiUrl: undefined,
cacheEnabled: true,
}
constructor() {
super("wiki")
}
protected async onInitialize(): Promise<void> {
console.log("[WikiPlugin] Initializing...")
// 加载配置
try {
const enabled = await ConfigService.get("auto_score_enabled")
this.config.enabled = enabled as boolean
} catch (error) {
console.error("[WikiPlugin] Failed to load config:", error)
}
// 注册配置变更监听
const unsubscribe = ConfigService.onChange((event: { key: string; value: any }) => {
if (event.key === "auto_score_enabled") {
this.config.enabled = event.value
console.log("[WikiPlugin] Config updated:", this.config.enabled)
}
})
// 注册清理函数
this.registerDisposer(() => {
unsubscribe()
console.log("[WikiPlugin] Disposed")
})
console.log("[WikiPlugin] Initialized successfully")
}
/**
* 获取 Wiki URL
*/
async setWikiUrl(url: string): Promise<void> {
this.config.wikiUrl = url
console.log("[WikiPlugin] Wiki URL set to:", url)
}
/**
* 获取当前配置
*/
getConfig(): WikiPluginConfig {
return { ...this.config }
}
}
/**
* 内置插件示例:通知插件
*/
export class NotificationPlugin extends DisposablePlugin {
private notifications: Array<{ id: string; message: string; timestamp: number }> = []
constructor() {
super("notification")
}
protected async onInitialize(): Promise<void> {
console.log("[NotificationPlugin] Initializing...")
// 可以在这里初始化通知系统
this.registerDisposer(() => {
this.notifications = []
console.log("[NotificationPlugin] Disposed")
})
console.log("[NotificationPlugin] Initialized successfully")
}
/**
* 发送通知
*/
notify(message: string): string {
const id = `notif_${Date.now()}`
this.notifications.push({
id,
message,
timestamp: Date.now(),
})
// 触发通知事件
window.dispatchEvent(
new CustomEvent("ss:notification", {
detail: { id, message, timestamp: Date.now() },
})
)
return id
}
/**
* 获取所有通知
*/
getNotifications(): typeof this.notifications {
return [...this.notifications]
}
/**
* 清除通知
*/
clear(id?: string): void {
if (id) {
this.notifications = this.notifications.filter((n) => n.id !== id)
} else {
this.notifications = []
}
}
}
/**
* 内置插件示例:数据导出插件
*/
export class DataExportPlugin extends DisposablePlugin {
private exportHistory: Array<{ id: string; type: string; timestamp: number; filename: string }> =
[]
constructor() {
super("data-export")
}
protected async onInitialize(): Promise<void> {
console.log("[DataExportPlugin] Initializing...")
this.registerDisposer(() => {
this.exportHistory = []
console.log("[DataExportPlugin] Disposed")
})
console.log("[DataExportPlugin] Initialized successfully")
}
/**
* 导出数据
*/
async exportData<T>(type: string, data: T, filename: string): Promise<void> {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
this.exportHistory.push({
id: `export_${Date.now()}`,
type,
timestamp: Date.now(),
filename,
})
console.log(`[DataExportPlugin] Exported ${type} to ${filename}`)
}
/**
* 获取导出历史
*/
getHistory(): typeof this.exportHistory {
return [...this.exportHistory]
}
}
-164
View File
@@ -1,164 +0,0 @@
import { useEffect, useState, useCallback } from "react"
import {
BuiltinPluginMeta,
getAvailablePlugins,
getInstalledPlugins,
installPlugin,
uninstallPlugin,
enablePlugin,
disablePlugin,
isPluginEnabled,
} from "../plugins/builtin"
/**
* React Hook: 使用内置插件系统
*/
export function useBuiltinPlugins() {
const [plugins, setPlugins] = useState<
Array<BuiltinPluginMeta & { installed: boolean; enabled: boolean }>
>([])
const [installedPlugins, setInstalledPlugins] = useState<
Array<BuiltinPluginMeta & { enabled: boolean; installedAt?: number }>
>([])
const [loading, setLoading] = useState(true)
const refreshPlugins = useCallback(async () => {
setLoading(true)
try {
const [available, installed] = await Promise.all([
getAvailablePlugins(),
getInstalledPlugins(),
])
setPlugins(available)
setInstalledPlugins(installed)
} catch (error) {
console.error("Failed to refresh plugins:", error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refreshPlugins()
// 监听插件状态变化
const handleStateChanged = () => {
refreshPlugins()
}
window.addEventListener("ss:plugins-state-changed", handleStateChanged)
window.addEventListener("ss:plugin-installed", handleStateChanged)
window.addEventListener("ss:plugin-uninstalled", handleStateChanged)
window.addEventListener("ss:plugin-enabled", handleStateChanged)
window.addEventListener("ss:plugin-disabled", handleStateChanged)
return () => {
window.removeEventListener("ss:plugins-state-changed", handleStateChanged)
window.removeEventListener("ss:plugin-installed", handleStateChanged)
window.removeEventListener("ss:plugin-uninstalled", handleStateChanged)
window.removeEventListener("ss:plugin-enabled", handleStateChanged)
window.removeEventListener("ss:plugin-disabled", handleStateChanged)
}
}, [refreshPlugins])
const install = useCallback(
async (pluginId: string) => {
const result = await installPlugin(pluginId)
if (result) {
await refreshPlugins()
}
return result
},
[refreshPlugins]
)
const uninstall = useCallback(
async (pluginId: string) => {
const result = await uninstallPlugin(pluginId)
if (result) {
await refreshPlugins()
}
return result
},
[refreshPlugins]
)
const enable = useCallback(
async (pluginId: string) => {
const result = await enablePlugin(pluginId)
if (result) {
await refreshPlugins()
}
return result
},
[refreshPlugins]
)
const disable = useCallback(
async (pluginId: string) => {
const result = await disablePlugin(pluginId)
if (result) {
await refreshPlugins()
}
return result
},
[refreshPlugins]
)
const isEnabled = useCallback(async (pluginId: string) => {
return await isPluginEnabled(pluginId)
}, [])
return {
plugins,
installedPlugins,
loading,
refreshPlugins,
install,
uninstall,
enable,
disable,
isEnabled,
}
}
/**
* React Hook: 检查特定插件是否启用
*/
export function usePluginEnabled(pluginId: string): boolean {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
let mounted = true
const checkEnabled = async () => {
try {
const result = await isPluginEnabled(pluginId)
if (mounted) {
setEnabled(result)
}
} catch (error) {
console.error(`Failed to check plugin ${pluginId} state:`, error)
}
}
checkEnabled()
const handleStateChanged = () => {
checkEnabled()
}
window.addEventListener("ss:plugins-state-changed", handleStateChanged)
window.addEventListener("ss:plugin-enabled", handleStateChanged)
window.addEventListener("ss:plugin-disabled", handleStateChanged)
return () => {
mounted = false
window.removeEventListener("ss:plugins-state-changed", handleStateChanged)
window.removeEventListener("ss:plugin-enabled", handleStateChanged)
window.removeEventListener("ss:plugin-disabled", handleStateChanged)
}
}, [pluginId])
return enabled
}
-99
View File
@@ -1,99 +0,0 @@
import { useEffect, useState, useCallback } from "react"
import { ConfigService, ConfigSpec } from "../services/ConfigService"
export function useConfig<K extends keyof ConfigSpec>(
key: K,
defaultValue?: ConfigSpec[K]
): [ConfigSpec[K], (value: ConfigSpec[K]) => Promise<void>, boolean] {
const [value, setValue] = useState<ConfigSpec[K]>(
ConfigService.has(key) ? ConfigService.getAll()[key] : (defaultValue as ConfigSpec[K])
)
const [loading, setLoading] = useState(!ConfigService.has(key))
useEffect(() => {
let mounted = true
const init = async () => {
try {
await ConfigService.initialize()
const currentValue = ConfigService.getAll()[key]
if (mounted) {
setValue(currentValue ?? defaultValue)
setLoading(false)
}
} catch (error) {
console.error("Failed to load config:", error)
if (mounted) {
setValue(defaultValue as ConfigSpec[K])
setLoading(false)
}
}
}
init()
const unsubscribe = ConfigService.onChange((event) => {
if (event.key === key && mounted) {
setValue(event.value)
}
})
return () => {
mounted = false
unsubscribe()
}
}, [key, defaultValue])
const updateValue = useCallback(
async (newValue: ConfigSpec[K]) => {
try {
await ConfigService.set(key, newValue)
setValue(newValue)
} catch (error) {
console.error("Failed to update config:", error)
throw error
}
},
[key]
)
return [value, updateValue, loading]
}
export function useConfigAll(): [ConfigSpec, (key: keyof ConfigSpec, value: any) => Promise<void>] {
const [config, setConfig] = useState<ConfigSpec>(ConfigService.getAll())
useEffect(() => {
let mounted = true
const init = async () => {
try {
await ConfigService.initialize()
if (mounted) {
setConfig(ConfigService.getAll())
}
} catch (error) {
console.error("Failed to load config:", error)
}
}
init()
const unsubscribe = ConfigService.onChange(() => {
if (mounted) {
setConfig(ConfigService.getAll())
}
})
return () => {
mounted = false
unsubscribe()
}
}, [])
const updateConfig = useCallback(async (key: keyof ConfigSpec, value: any) => {
await ConfigService.set(key, value)
}, [])
return [config, updateConfig]
}
-55
View File
@@ -1,55 +0,0 @@
import { useCallback } from "react"
import { logger, LogLevel } from "../services/LoggerService"
/**
* React Hook: 使用日志服务
*/
export function useLogger(source?: string) {
const log = useCallback(
(_level: LogLevel, message: string, meta?: any) => {
if (source) {
const childLogger = logger.createChild(source)
childLogger.debug(message, meta)
} else {
logger.debug(message, meta)
}
},
[source]
)
const debug = useCallback(
(message: string, meta?: any) => {
log("debug", message, meta)
},
[log]
)
const info = useCallback(
(message: string, meta?: any) => {
log("info", message, meta)
},
[log]
)
const warn = useCallback(
(message: string, meta?: any) => {
log("warn", message, meta)
},
[log]
)
const error = useCallback(
(message: string, meta?: any) => {
log("error", message, meta)
},
[log]
)
return {
debug,
info,
warn,
error,
log,
}
}
-7
View File
@@ -8,21 +8,14 @@ import { ClientContext } from "./ClientContext"
import { StudentService } from "./services/StudentService"
import { ServiceProvider } from "./contexts/ServiceContext"
import { api } from "./preload/types"
import { initBuiltinPlugins } from "./plugins/builtin"
if (!(window as any).api) {
;(window as any).api = api
}
const ctx = new ClientContext()
ctx.initializeDI()
new StudentService(ctx)
// 初始化内置插件系统
initBuiltinPlugins().catch((error) => {
console.error("Failed to initialize builtin plugins:", error)
})
const safeWriteLog = (payload: {
level: "debug" | "info" | "warn" | "error"
message: string
-288
View File
@@ -1,288 +0,0 @@
/**
* SecScore 内置插件系统
* 将非核心功能作为内置插件提供,默认不安装
*/
import { DisposablePlugin } from "../../di/DisposablePlugin"
// 插件元数据接口
export interface BuiltinPluginMeta {
id: string
name: string
description: string
version: string
author: string
icon?: string
category: "automation" | "visualization" | "management" | "integration"
defaultEnabled: boolean
requiresAdmin: boolean
}
// 内置插件注册表
export const BUILTIN_PLUGINS: BuiltinPluginMeta[] = [
{
id: "auto-score",
name: "自动评分",
description: "基于规则的自动评分系统,支持定时任务和条件触发",
version: "1.0.0",
author: "SecScore Team",
category: "automation",
defaultEnabled: false,
requiresAdmin: true,
},
{
id: "boards",
name: "看板管理",
description: "可视化数据看板,支持自定义布局和实时数据展示",
version: "1.0.0",
author: "SecScore Team",
category: "visualization",
defaultEnabled: false,
requiresAdmin: true,
},
{
id: "settlements",
name: "结算历史",
description: "积分结算记录管理,支持导出和统计分析",
version: "1.0.0",
author: "SecScore Team",
category: "management",
defaultEnabled: false,
requiresAdmin: false,
},
{
id: "reward-settings",
name: "奖励设置",
description: "奖励兑换系统配置,管理奖励项目和兑换规则",
version: "1.0.0",
author: "SecScore Team",
category: "management",
defaultEnabled: false,
requiresAdmin: true,
},
{
id: "cloud-sync",
name: "云同步",
description: "SECTL 云服务集成,支持数据云同步和跨设备访问",
version: "1.0.0",
author: "SecScore Team",
category: "integration",
defaultEnabled: false,
requiresAdmin: true,
},
]
// 插件状态存储键(用于 future use)
// const PLUGIN_STATE_KEY = "builtin_plugins_state"
// 插件状态接口
export interface PluginState {
[pluginId: string]: {
enabled: boolean
installed: boolean
installedAt?: number
config?: Record<string, unknown>
}
}
const PLUGIN_STATE_STORAGE_KEY = "secscore_builtin_plugins_state"
/**
* 获取插件状态
*/
export async function getPluginState(): Promise<PluginState> {
try {
const stored = localStorage.getItem(PLUGIN_STATE_STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
/**
* 保存插件状态
*/
export async function savePluginState(state: PluginState): Promise<void> {
try {
localStorage.setItem(PLUGIN_STATE_STORAGE_KEY, JSON.stringify(state))
} catch (error) {
console.error("Failed to save plugin state:", error)
}
}
/**
* 检查插件是否已安装
*/
export async function isPluginInstalled(pluginId: string): Promise<boolean> {
const state = await getPluginState()
return state[pluginId]?.installed || false
}
/**
* 检查插件是否已启用
*/
export async function isPluginEnabled(pluginId: string): Promise<boolean> {
const state = await getPluginState()
return state[pluginId]?.enabled || false
}
/**
* 安装插件
*/
export async function installPlugin(pluginId: string): Promise<boolean> {
const meta = BUILTIN_PLUGINS.find((p) => p.id === pluginId)
if (!meta) return false
const state = await getPluginState()
state[pluginId] = {
enabled: meta.defaultEnabled,
installed: true,
installedAt: Date.now(),
config: {},
}
await savePluginState(state)
// 触发插件安装事件
window.dispatchEvent(new CustomEvent("ss:plugin-installed", { detail: { pluginId } }))
return true
}
/**
* 卸载插件
*/
export async function uninstallPlugin(pluginId: string): Promise<boolean> {
const state = await getPluginState()
if (state[pluginId]) {
state[pluginId].installed = false
state[pluginId].enabled = false
await savePluginState(state)
// 触发插件卸载事件
window.dispatchEvent(new CustomEvent("ss:plugin-uninstalled", { detail: { pluginId } }))
}
return true
}
/**
* 启用插件
*/
export async function enablePlugin(pluginId: string): Promise<boolean> {
const state = await getPluginState()
if (!state[pluginId]?.installed) {
// 如果未安装,先安装
await installPlugin(pluginId)
}
state[pluginId].enabled = true
await savePluginState(state)
// 触发插件启用事件
window.dispatchEvent(new CustomEvent("ss:plugin-enabled", { detail: { pluginId } }))
return true
}
/**
* 禁用插件
*/
export async function disablePlugin(pluginId: string): Promise<boolean> {
const state = await getPluginState()
if (state[pluginId]) {
state[pluginId].enabled = false
await savePluginState(state)
// 触发插件禁用事件
window.dispatchEvent(new CustomEvent("ss:plugin-disabled", { detail: { pluginId } }))
}
return true
}
/**
* 获取已安装的内置插件列表
*/
export async function getInstalledPlugins(): Promise<
Array<BuiltinPluginMeta & { enabled: boolean; installedAt?: number }>
> {
const state = await getPluginState()
return BUILTIN_PLUGINS.filter((p) => state[p.id]?.installed).map((p) => ({
...p,
enabled: state[p.id]?.enabled || false,
installedAt: state[p.id]?.installedAt,
}))
}
/**
* 获取可用的内置插件列表
*/
export async function getAvailablePlugins(): Promise<
Array<BuiltinPluginMeta & { installed: boolean; enabled: boolean }>
> {
const state = await getPluginState()
return BUILTIN_PLUGINS.map((p) => ({
...p,
installed: state[p.id]?.installed || false,
enabled: state[p.id]?.enabled || false,
}))
}
/**
* 初始化内置插件系统
* 在应用启动时调用
*/
export async function initBuiltinPlugins(): Promise<void> {
// 确保状态存在
const state = await getPluginState()
// 为每个插件初始化默认状态
let hasChanges = false
for (const plugin of BUILTIN_PLUGINS) {
if (!state[plugin.id]) {
state[plugin.id] = {
enabled: false,
installed: false,
config: {},
}
hasChanges = true
}
}
if (hasChanges) {
await savePluginState(state)
}
console.log("[BuiltinPlugins] Initialized")
}
/**
* 内置插件管理器类
*/
export class BuiltinPluginManager extends DisposablePlugin {
constructor() {
super("builtin-plugin-manager")
}
protected async onInitialize(): Promise<void> {
console.log("[BuiltinPluginManager] Initializing...")
await initBuiltinPlugins()
// 监听 localStorage 变化
const handleStorageChange = (event: StorageEvent) => {
if (event.key === PLUGIN_STATE_STORAGE_KEY) {
console.log("[BuiltinPluginManager] Plugin state changed")
window.dispatchEvent(new CustomEvent("ss:plugins-state-changed"))
}
}
window.addEventListener("storage", handleStorageChange)
this.registerDisposer(() => {
window.removeEventListener("storage", handleStorageChange)
})
console.log("[BuiltinPluginManager] Initialized")
}
}
// 导出全局实例
export const builtinPluginManager = new BuiltinPluginManager()
+1 -6
View File
@@ -852,12 +852,6 @@ const api = {
message?: string
}> => invoke("plugin_get_runtime_modules"),
// Settings Window
openSettingsWindow: (): Promise<{ success: boolean; message?: string }> =>
invoke("open_settings_window"),
closeSettingsWindow: (): Promise<{ success: boolean; message?: string }> =>
invoke("close_settings_window"),
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
invoke: async (channel: string): Promise<any> => {
switch (channel) {
@@ -869,3 +863,4 @@ const api = {
export default api
export { api }
-150
View File
@@ -1,150 +0,0 @@
import { invoke } from "@tauri-apps/api/core"
export interface ConfigChangeEvent {
key: string
value: any
timestamp: number
}
export type ConfigChangeListener = (event: ConfigChangeEvent) => void
export interface ConfigSpec {
// 应用设置
is_wizard_completed: boolean
log_level: "debug" | "info" | "warn" | "error"
window_zoom: number
search_keyboard_layout: "t9" | "qwerty26"
disable_search_keyboard: boolean
font_family: string
current_theme_id: string
themes_custom: any[]
dashboards_config: any[]
mobile_bottom_nav_items: string[]
// 自动评分
auto_score_enabled: boolean
auto_score_rules: any[]
auto_score_batches: any[]
// 数据库
pg_connection_string: string
pg_connection_status: {
connected: boolean
type: "sqlite" | "postgresql"
error?: string
}
}
class ConfigServiceClass {
private cache: Map<string, any> = new Map()
private listeners: Set<ConfigChangeListener> = new Set()
private initialized = false
private initPromise: Promise<void> | null = null
async initialize(): Promise<void> {
if (this.initialized) return
if (this.initPromise) return this.initPromise
this.initPromise = (async () => {
try {
const result = await this.invokeApi("getAllSettings")
if (result.success && result.data) {
Object.entries(result.data).forEach(([key, value]) => {
this.cache.set(key, value)
})
}
this.setupListener()
this.initialized = true
} catch (error) {
console.error("Failed to initialize config service:", error)
throw error
}
})()
return this.initPromise
}
async get<K extends keyof ConfigSpec>(key: K): Promise<ConfigSpec[K]> {
await this.initialize()
return this.cache.get(key)
}
async set<K extends keyof ConfigSpec>(key: K, value: ConfigSpec[K]): Promise<void> {
await this.initialize()
const result = await this.invokeApi("setSetting", [key, value])
if (!result.success) {
throw new Error(result.message || "Failed to set config")
}
this.cache.set(key, value)
this.notifyListeners({ key, value, timestamp: Date.now() })
}
async patch(partial: Partial<ConfigSpec>): Promise<void> {
await this.initialize()
for (const [key, value] of Object.entries(partial)) {
await this.set(key as keyof ConfigSpec, value)
}
}
getAll(): ConfigSpec {
const entries = Array.from(this.cache.entries())
return Object.fromEntries(entries) as ConfigSpec
}
has(key: string): boolean {
return this.cache.has(key)
}
onChange(listener: ConfigChangeListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private notifyListeners(event: ConfigChangeEvent): void {
this.listeners.forEach((listener) => {
try {
listener(event)
} catch (error) {
console.error("Config listener error:", error)
}
})
}
private setupListener(): void {
const api = (window as any).api
if (!api?.onSettingChanged) return
api
.onSettingChanged((change: any) => {
if (change?.key && change?.value) {
this.cache.set(change.key, change.value)
this.notifyListeners({
key: change.key,
value: change.value,
timestamp: Date.now(),
})
}
})
.catch(() => void 0)
}
private async invokeApi<T = any>(
command: string,
args?: any[]
): Promise<{ success: boolean; data?: T; message?: string }> {
const api = (window as any).api
if (api?.[command]) {
return await api[command](...(args || []))
}
// Fallback to invoke if API not available
try {
const result = await invoke(command, args ? { args } : {})
return { success: true, data: result as T }
} catch (error: any) {
return { success: false, message: error.message || "Unknown error" }
}
}
}
export const ConfigService = new ConfigServiceClass()
-230
View File
@@ -1,230 +0,0 @@
/**
* SecScore 日志服务
* 支持分级日志、多色打印、文件存储
*/
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface LogEntry {
level: LogLevel
message: string
timestamp: number
meta?: any
source?: string
}
export interface LogWriter {
write(entry: LogEntry): void
}
/**
* 控制台日志写入器
* 支持多色打印
*/
class ConsoleLogWriter implements LogWriter {
private colors: Record<LogLevel, string> = {
debug: "\x1b[36m", // Cyan
info: "\x1b[32m", // Green
warn: "\x1b[33m", // Yellow
error: "\x1b[31m", // Red
}
private reset = "\x1b[0m"
write(entry: LogEntry): void {
const { level, message, timestamp, meta, source } = entry
const color = this.colors[level]
const time = new Date(timestamp).toISOString()
const prefix = source ? `[${source}]` : "[SecScore]"
const logMessage = `${color}${time} ${prefix} [${level.toUpperCase()}]${this.reset} ${message}`
switch (level) {
case "debug":
console.debug(logMessage, meta || "")
break
case "info":
console.info(logMessage, meta || "")
break
case "warn":
console.warn(logMessage, meta || "")
break
case "error":
console.error(logMessage, meta || "")
break
}
}
}
/**
* 远程日志写入器
* 将日志发送到 Rust 后端
*/
class RemoteLogWriter implements LogWriter {
write(entry: LogEntry): void {
const api = (window as any).api
if (!api?.writeLog) return
const payload = {
level: entry.level,
message: entry.message,
meta: entry.meta,
}
Promise.resolve(api.writeLog(payload)).catch(() => void 0)
}
}
/**
* 日志服务类
*/
class LoggerServiceClass {
private writers: LogWriter[] = []
private minLevel: LogLevel = "info"
private levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
}
private source: string = "App"
private buffer: LogEntry[] = []
private bufferSize = 1000
constructor() {
// 默认添加控制台和远程写入器
this.addWriter(new ConsoleLogWriter())
this.addWriter(new RemoteLogWriter())
}
/**
* 设置日志源
*/
setSource(source: string): void {
this.source = source
}
/**
* 添加日志写入器
*/
addWriter(writer: LogWriter): void {
this.writers.push(writer)
}
/**
* 设置最低日志级别
*/
setLevel(level: LogLevel): void {
this.minLevel = level
}
/**
* 获取当前日志级别
*/
getLevel(): LogLevel {
return this.minLevel
}
/**
* 写入日志
*/
private log(level: LogLevel, message: string, meta?: any): void {
if (this.levelPriority[level] < this.levelPriority[this.minLevel]) {
return
}
const entry: LogEntry = {
level,
message,
timestamp: Date.now(),
meta,
source: this.source,
}
// 添加到缓冲区
this.buffer.push(entry)
if (this.buffer.length > this.bufferSize) {
this.buffer.shift()
}
// 写入所有写入器
this.writers.forEach((writer) => {
try {
writer.write(entry)
} catch (error) {
console.error("Log writer error:", error)
}
})
}
/**
* Debug 日志
*/
debug(message: string, meta?: any): void {
this.log("debug", message, meta)
}
/**
* Info 日志
*/
info(message: string, meta?: any): void {
this.log("info", message, meta)
}
/**
* Warning 日志
*/
warn(message: string, meta?: any): void {
this.log("warn", message, meta)
}
/**
* Error 日志
*/
error(message: string, meta?: any): void {
this.log("error", message, meta)
}
/**
* 创建带指定源的日志记录器
*/
createChild(source: string): LoggerServiceClass {
const child = new LoggerServiceClass()
child.writers = [...this.writers]
child.minLevel = this.minLevel
child.source = source
return child
}
/**
* 获取最近的日志
*/
getRecentLogs(count: number = 100): LogEntry[] {
return this.buffer.slice(-count)
}
/**
* 清空日志缓冲
*/
clear(): void {
this.buffer = []
}
}
// 导出全局日志服务实例
export const LoggerService = new LoggerServiceClass()
/**
* 便捷日志函数
*/
export const logger = {
debug: (message: string, meta?: any) => LoggerService.debug(message, meta),
info: (message: string, meta?: any) => LoggerService.info(message, meta),
warn: (message: string, meta?: any) => LoggerService.warn(message, meta),
error: (message: string, meta?: any) => LoggerService.error(message, meta),
createChild: (source: string) => LoggerService.createChild(source),
setLevel: (level: LogLevel) => LoggerService.setLevel(level),
getLevel: () => LoggerService.getLevel(),
getRecentLogs: (count?: number) => LoggerService.getRecentLogs(count),
clear: () => LoggerService.clear(),
}
-176
View File
@@ -1,176 +0,0 @@
import "./assets/main.css"
import "./i18n"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ConfigProvider, theme as antdTheme } from "antd"
import { Settings } from "./components/Settings"
import { ThemeProvider } from "./contexts/ThemeContext"
import { ServiceProvider } from "./contexts/ServiceContext"
import { ClientContext } from "./ClientContext"
import { api } from "./preload/types"
// 确保 API 可用
if (!(window as any).api) {
;(window as any).api = api
}
// 创建 ClientContext 实例
const clientContext = new ClientContext()
clientContext.initializeDI()
// 设置窗口应用
const SettingsWindowApp = () => {
return (
<ServiceProvider value={clientContext}>
<ThemeProvider>
<ConfigProvider
theme={{
algorithm: antdTheme.defaultAlgorithm,
}}
>
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{/* 自定义标题栏 */}
<div
data-tauri-drag-region
style={{
height: "40px",
background: "var(--ss-card-bg, #fff)",
borderBottom: "1px solid var(--ss-border, #e8e8e8)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 16px",
// @ts-ignore - Tauri specific property
WebkitAppRegion: "drag",
userSelect: "none",
flexShrink: 0,
}}
>
<div style={{ fontWeight: 500, color: "var(--ss-text-main, #000)" }}>
SecScore
</div>
<div
style={{
display: "flex",
gap: "4px",
// @ts-ignore - Tauri specific property
WebkitAppRegion: "no-drag",
}}
>
<button
onClick={async () => {
const api = (window as any).api
if (api?.windowMinimize) {
await api.windowMinimize()
}
}}
style={{
width: "32px",
height: "32px",
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "4px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--ss-hover-bg, #f5f5f5)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent"
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="0" y="5" width="12" height="2" />
</svg>
</button>
<button
onClick={async () => {
const api = (window as any).api
if (api?.windowMaximize) {
await api.windowMaximize()
}
}}
style={{
width: "32px",
height: "32px",
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "4px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--ss-hover-bg, #f5f5f5)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent"
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect
x="1"
y="1"
width="10"
height="10"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
</svg>
</button>
<button
onClick={async () => {
const api = (window as any).api
if (api?.closeSettingsWindow) {
await api.closeSettingsWindow()
}
}}
style={{
width: "32px",
height: "32px",
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "4px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#ff4d4f"
e.currentTarget.style.color = "#fff"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent"
e.currentTarget.style.color = "inherit"
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M1 1L11 11M1 11L11 1" stroke="currentColor" strokeWidth="1.5" />
</svg>
</button>
</div>
</div>
{/* 设置内容 */}
<div style={{ flex: 1, overflow: "auto" }}>
<Settings permission="admin" />
</div>
</div>
</ConfigProvider>
</ThemeProvider>
</ServiceProvider>
)
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SettingsWindowApp />
</StrictMode>
)
-13
View File
@@ -1,13 +0,0 @@
/**
* 服务注册表类型定义
*/
export interface ServiceProvideOptions {
overwrite?: boolean
immediate?: boolean
}
export interface ServiceWatcherMeta {
name: string
owner?: string
}
+1 -7
View File
@@ -11,17 +11,11 @@ export default defineConfig({
},
optimizeDeps: {
// Avoid scanning legacy `old-ss` entries under project root.
entries: ["index.html", "settings-window.html"],
entries: ["index.html"],
},
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
"settings-window": resolve(__dirname, "settings-window.html"),
},
},
},
server: {
host: process.env.TAURI_DEV_HOST || false,