chore:删除旧代码

This commit is contained in:
Fox_block
2026-03-21 12:48:10 +08:00
parent bd0fa2375d
commit 92929012fc
176 changed files with 8 additions and 26183 deletions
+8
View File
@@ -0,0 +1,8 @@
---
trigger: always_on
---
1.每次完成任务之前必须跑一遍测试(比如说npm系的typecheck/lint
2.每次完成任务之前必须跑一遍格式化(比如说npm系的prettier
3.任务完成时详细输出你所做的事情和需要用户确认的问题(如有)
4.每次规划任务必须按照步骤来,每个步骤结束后遵循第三条规定。
-9
View File
@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
-20
View File
@@ -1,20 +0,0 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
logs
docs
configs
build/*
!build/entitlements.mac.plist
db.sqlite
*.local
.env.local
.env.*.local
.vscode
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
-3
View File
@@ -1,3 +0,0 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true
-6
View File
@@ -1,6 +0,0 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
-4
View File
@@ -1,4 +0,0 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
-101
View File
@@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-54
View File
@@ -1,54 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = "com.sectl.secscore"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.sectl.secscore"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
-19
View File
@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-preferences')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
-21
View File
@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,5 +0,0 @@
package com.sectl.secscore;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">SecScore</string>
<string name="title_activity_main">SecScore</string>
<string name="package_name">com.sectl.secscore</string>
<string name="custom_url_scheme">com.sectl.secscore</string>
</resources>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
-29
View File
@@ -1,29 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
-6
View File
@@ -1,6 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
-22
View File
@@ -1,22 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-251
View File
@@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-94
View File
@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-5
View File
@@ -1,5 +0,0 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
-16
View File
@@ -1,16 +0,0 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
-26
View File
@@ -1,26 +0,0 @@
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'com.sectl.secscore',
appName: 'SecScore',
webDir: 'out',
server: {
androidScheme: 'https'
},
plugins: {
Preferences: {
group: 'SecScore'
}
},
android: {
buildOptions: {
keystorePath: undefined,
keystorePassword: undefined,
keystoreAlias: undefined,
keystoreAliasPassword: undefined,
signingType: 'apksigner'
}
}
}
export default config
-75
View File
@@ -1,75 +0,0 @@
appId: com.secscore.app
productName: SecScore
directories:
buildResources: build
protocols:
- name: SecScore URL Protocol
schemes:
- secscore
role: Viewer
files:
- '**/*'
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!**/node_modules/get-intrinsic/**'
- '!**/node_modules/dunder-proto/**'
- '!**/node_modules/call-bind/**'
- '!**/node_modules/define-data-property/**'
- '!**/node_modules/has-property-descriptors/**'
- '!**/node_modules/gopd/**'
asarUnpack:
- resources/**
extraFiles:
- from: themes
to: themes
- from: node_modules/get-intrinsic
to: node_modules/get-intrinsic
- from: node_modules/dunder-proto
to: node_modules/dunder-proto
- from: node_modules/call-bind
to: node_modules/call-bind
- from: node_modules/define-data-property
to: node_modules/define-data-property
- from: node_modules/has-property-descriptors
to: node_modules/has-property-descriptors
- from: node_modules/gopd
to: node_modules/gopd
extraResources:
- from: resources
to: assets
win:
target:
- zip
executableName: SecScore
icon: resources/SecScore_logo.ico
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
allowToChangeInstallationDirectory: true
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
-22
View File
@@ -1,22 +0,0 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
build: {
rollupOptions: {
external: []
}
}
},
preload: {},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()]
}
})
-40
View File
@@ -1,40 +0,0 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out', 'scripts/**', 'secrandom_ipc_send_url.js'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'off',
'@typescript-eslint/no-require-imports': 'off',
// we use TypeScript types instead of PropTypes in React components
'react/prop-types': 'off'
}
},
eslintConfigPrettier
)
-98
View File
@@ -1,98 +0,0 @@
{
"name": "secscore",
"version": "1.0.0",
"description": "SecScore Your Education Points Management Expert",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://example.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"lint:fix": "eslint --cache . --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm -s typecheck:node && pnpm -s typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "node scripts/clean-db.mjs && pnpm -s typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm -s build && electron-builder --dir --publish never",
"build:win": "pnpm -s build && electron-builder --win --publish never",
"build:mac": "electron-vite build && electron-builder --mac --publish never",
"build:linux": "electron-vite build && electron-builder --linux --publish never",
"build:mobile": "vite build --config vite.mobile.config.ts",
"cap:sync": "npx cap sync",
"cap:open:android": "npx cap open android",
"mobile:build": "pnpm build:mobile && pnpm cap:sync",
"mobile:android": "pnpm mobile:build && npx cap open android"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@capacitor/android": "^8.1.0",
"@capacitor/cli": "^8.1.0",
"@capacitor/core": "^8.1.0",
"@capacitor/preferences": "^8.0.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@react-querybuilder/antd": "^8.14.0",
"@react-querybuilder/dnd": "^8.14.0",
"antd": "^6.3.1",
"antd-mobile": "^5.42.3",
"better-sqlite3": "^12.6.0",
"chokidar": "^5.0.0",
"dayjs": "^1.11.20",
"es-object-atoms": "^1.1.1",
"express": "^5.2.1",
"i18next": "^25.8.14",
"json-rules-engine": "^7.3.1",
"math-intrinsics": "^1.1.0",
"mica-electron": "^1.5.16",
"os": "^0.1.2",
"pg": "^8.19.0",
"pinyin-pro": "^3.27.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dnd-touch-backend": "^16.0.1",
"react-i18next": "^16.5.6",
"react-querybuilder": "^8.14.0",
"react-router-dom": "^6.28.0",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.27",
"uuid": "^13.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"terser": "^5.46.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild"
]
}
}
-9058
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW (OEM 版本) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="57.5732mm" height="57.5732mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 5757.32 5757.32"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#FEFEFE;stroke-width:20;stroke-miterlimit:22.9256}
.str1 {stroke:#2387ED;stroke-width:20;stroke-miterlimit:22.9256}
.str2 {stroke:#2789ED;stroke-width:20;stroke-miterlimit:22.9256}
.fil0 {fill:#FEFEFE}
.fil1 {fill:#2389EC}
.fil4 {fill:#75BDF7}
.fil2 {fill:#FFDA31}
.fil3 {fill:#FFF270}
]]>
</style>
</defs>
<g id="圖層_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1556526623248">
<rect class="fil0 str0" x="27.11" y="26.34" width="5675.33" height="5687.62" rx="1140.46" ry="1142.93"/>
<circle class="fil1 str1" cx="3484.74" cy="2200.34" r="920.68"/>
<circle class="fil0 str0" cx="3951.4" cy="3983.83" r="1288.52"/>
<g>
<circle class="fil2" cx="3951.4" cy="3983.83" r="1033.53"/>
<path class="fil3" d="M4139.76 3707.08c-14.94,-25.96 -43.54,-77.79 -66.66,-124.1 -23.11,-46.3 -40.75,-87.08 -61.13,-131.88 -20.37,-44.8 -43.48,-93.63 -73.05,-71.06 -29.57,22.56 -65.6,116.49 -95.71,187.21 -30.1,70.71 -54.3,118.2 -66.4,141.95 -12.1,23.74 -12.1,23.74 -12.1,23.74 0,0 0,0 -36.04,5.26 -36.03,5.25 -108.1,15.76 -187.6,26.12 -79.5,10.37 -166.42,20.58 -177.47,52.05 -11.04,31.46 53.79,84.19 113.27,132.93 59.48,48.74 113.6,93.5 140.66,115.88 27.06,22.38 27.06,22.38 27.06,22.38 0,0 0,0 -4.8,29.82 -4.81,29.82 -14.42,89.45 -29.87,166.02 -15.45,76.57 -36.73,170.06 -13.79,197.73 22.93,27.66 90.08,-10.5 157.94,-50.54 67.86,-40.04 136.43,-81.95 170.71,-102.91 34.28,-20.96 34.28,-20.96 34.28,-20.96 0,0 0,0 29.08,17.71 29.07,17.71 87.22,53.13 150.46,92.03 63.23,38.89 131.56,81.25 159.24,64.16 27.68,-17.1 14.73,-93.67 3.9,-169.09 -10.82,-75.42 -19.51,-149.7 -23.86,-186.84 -4.35,-37.13 -4.35,-37.13 58.7,-87.16 63.04,-50.02 189.13,-150.07 193.62,-207.02 4.48,-56.94 -112.64,-70.78 -173.29,-77.95 -60.65,-7.17 -64.83,-7.68 -79.61,-9.01 -14.78,-1.33 -40.17,-3.48 -61.94,-5.33 -21.77,-1.86 -39.91,-3.4 -49.64,-4.23 -9.72,-0.82 -11.01,-0.94 -25.96,-26.91z"/>
</g>
<ellipse class="fil0" cx="2140.85" cy="2197.59" rx="1194.99" ry="1178.43"/>
<circle class="fil4" cx="2124.15" cy="2207.61" r="923.37"/>
<path class="fil1 str2" d="M2788.24 3393.56c-11.75,24.43 -34.95,73.29 -54.62,123.82 -19.67,50.52 -35.81,102.71 -53.14,186.31 -17.34,83.6 -35.86,198.63 -29.23,317.47 6.63,118.84 38.41,241.5 66,328.24 27.59,86.75 50.98,137.57 62.68,162.99 11.7,25.41 11.7,25.41 11.7,25.41 0,0 0,0 -278.03,0 -278.03,0 -834.08,0 -1112.11,0 -278.02,0 -278.02,0 -291.55,0.23 -13.53,0.23 -40.59,0.69 -75.21,-7.93 -34.61,-8.62 -76.78,-26.31 -107.12,-64.94 -30.33,-38.64 -48.83,-98.21 -57.28,-137.12 -8.46,-38.92 -6.87,-57.19 -6.07,-82.24 0.79,-25.05 0.79,-56.9 0.79,-80.43 0,-23.54 0,-38.77 1.5,-69.12 1.5,-30.35 4.5,-75.83 9.34,-112.5 4.84,-36.68 11.52,-64.54 44.17,-133.73 32.66,-69.18 91.29,-179.68 171.72,-260.68 80.45,-81 182.69,-132.49 273.98,-164.28 91.29,-31.78 171.63,-43.86 218.71,-49.89 47.09,-6.04 60.94,-6.04 74.09,-6.04 13.15,0 25.61,0 212.92,0 187.31,0 549.47,0 730.87,0 181.4,0 182.04,0 182.71,0 0.67,0 1.37,0 3.04,0 1.68,0 4.31,0 6.31,0 2.01,0 3.37,0 3.98,0 0.62,0 0.48,0 0.72,0 0.24,0 0.86,0 1.02,0 0.16,0 -0.14,0 -11.89,24.43z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

-10
View File
@@ -1,10 +0,0 @@
const fs = require('fs')
const path = require('path')
const afterPack = async (context) => {
const appOutDir = context.appOutDir
fs.mkdirSync(path.join(appOutDir, 'data'), { recursive: true })
}
module.exports = afterPack
module.exports.default = afterPack
-72
View File
@@ -1,72 +0,0 @@
import fs from 'fs'
import path from 'path'
const rawVersion = String(process.argv[2] || '')
.trim()
.replace(/^v/i, '')
if (!rawVersion) {
process.stderr.write('缺少版本号参数,例如:node scripts/ci/apply-version.mjs 1.2.3\n')
process.exit(1)
}
const semverRe = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/
function toSemver(version) {
if (semverRe.test(version)) {
return version
}
const parts = version.split(/[._-]/)
const nums = parts.filter((p) => /^\d+$/.test(p))
const nonNums = parts.filter((p) => !/^\d+$/.test(p) && p.length > 0)
let major = '0',
minor = '0',
patch = '0'
let prerelease = ''
let buildMeta = ''
if (nums.length >= 3) {
major = nums[0]
minor = nums[1]
patch = nums[2]
if (nums.length > 3) {
buildMeta = nums.slice(3).join('.')
}
} else if (nums.length === 2) {
major = nums[0]
minor = nums[1]
patch = '0'
} else if (nums.length === 1) {
major = nums[0]
minor = '0'
patch = '0'
}
if (nonNums.length > 0) {
if (prerelease) {
prerelease += '.' + nonNums.join('.')
} else {
prerelease = nonNums.join('.')
}
}
let result = `${major}.${minor}.${patch}`
if (prerelease) {
result += `-${prerelease}`
}
if (buildMeta) {
result += `+${buildMeta}`
}
return result
}
const version = toSemver(rawVersion)
process.stdout.write(`版本号转换: "${rawVersion}" -> "${version}"\n`)
const pkgPath = path.join(process.cwd(), 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.version = version
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8')
-98
View File
@@ -1,98 +0,0 @@
import fs from 'fs'
import path from 'path'
const cwd = process.cwd()
const pkgPath = path.join(cwd, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
const scripts = pkg?.scripts ?? {}
const rawMessage =
process.env.RELEASE_MESSAGE ??
process.env.GITHUB_COMMIT_MESSAGE ??
process.argv.slice(2).join(' ') ??
''
const message = String(rawMessage || '').trim()
// 是否以 release: 开头
const isReleasePrefix = /^release:/i.test(message)
const semverRe = /\bv?(\S+)/
const versionMatch = message.match(semverRe)
const version = versionMatch ? versionMatch[1] : ''
const buildToken =
message.match(/\b(build:(?:win|mac|linux|unpack|all))\b/i)?.[1]?.toLowerCase() ?? ''
let buildScript =
buildToken === 'build:all'
? 'build:all'
: buildToken && buildToken.startsWith('build:')
? buildToken
: ''
// 如果是 release: 开头且没有显式指定构建脚本,默认为 build:all
if (isReleasePrefix && !buildScript) {
buildScript = 'build:all'
}
const supportedBuildScripts = new Set(['build:win', 'build:mac', 'build:linux', 'build:unpack'])
const hasBuildScript = buildScript
? buildScript === 'build:all'
? [...supportedBuildScripts].every((s) => typeof scripts[s] === 'string' && scripts[s])
: typeof scripts[buildScript] === 'string' && scripts[buildScript]
: false
const fail = (text) => {
process.stderr.write(`${text}\n`)
process.exit(1)
}
const hasAnySignal = Boolean(isReleasePrefix || version || buildScript)
const shouldSkip = !hasAnySignal
if (!shouldSkip) {
if (!version) {
fail(
[
'未在提交信息中检测到版本号。',
'示例:release: v1.2.3 build:win 或 release: 1.2.3',
'支持格式:v1.2.3 或 1.2.3(可带 -beta.1 等后缀)'
].join('\n')
)
}
if (!buildScript) {
fail(
[
'未在提交信息中检测到构建命令标记。',
'示例:release: v1.2.3 build:win',
'支持:build:win | build:mac | build:linux | build:unpack | build:all'
].join('\n')
)
}
if (!hasBuildScript) {
fail(`构建脚本不存在或为空:${buildScript}`)
}
}
const runWin = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:win')
const runMac = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:mac')
const runLinux = !shouldSkip && (buildScript === 'build:all' || buildScript === 'build:linux')
const runUnpack = !shouldSkip && buildScript === 'build:unpack'
const outputs = {
version: shouldSkip ? '' : version,
tag: shouldSkip ? '' : `v${version}`,
build_script: shouldSkip ? '' : buildScript,
run_win: String(runWin),
run_mac: String(runMac),
run_linux: String(runLinux),
run_unpack: String(runUnpack)
}
if (process.env.GITHUB_OUTPUT) {
const lines = Object.entries(outputs).map(([k, v]) => `${k}=${v}`)
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`)
} else {
process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`)
}
-21
View File
@@ -1,21 +0,0 @@
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const targets = ['db.sqlite']
for (const name of targets) {
const filePath = path.join(root, name)
try {
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true })
console.log(`[clean-db] removed ${filePath}`)
}
} catch (e) {
if (e.code === 'EPERM' || e.code === 'EACCES') {
console.warn(`[clean-db] skipped ${filePath}: file in use or permission denied`)
} else {
console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e)
}
}
}
-23
View File
@@ -1,23 +0,0 @@
import { Context as BaseContext } from '../shared/kernel'
import { ipcMain } from 'electron'
export class MainContext extends BaseContext {
public isQuitting = false
constructor() {
super()
}
handle(
channel: string,
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise<any> | any
) {
ipcMain.handle(channel, listener)
this.effect(() => ipcMain.removeHandler(channel))
}
ipcOn(channel: string, listener: (event: Electron.IpcMainEvent, ...args: any[]) => void) {
ipcMain.on(channel, listener)
this.effect(() => ipcMain.removeListener(channel, listener))
}
}
-245
View File
@@ -1,245 +0,0 @@
import { Context, Service } from '../../shared/kernel'
import { DataSource } from 'typeorm'
import path from 'path'
import fs from 'fs'
import { StudentEntity } from './entities/StudentEntity'
import { ReasonEntity } from './entities/ReasonEntity'
import { ScoreEventEntity } from './entities/ScoreEventEntity'
import { SettlementEntity } from './entities/SettlementEntity'
import { SettingEntity } from './entities/SettingEntity'
import { TagEntity } from './entities/TagEntity'
import { StudentTagEntity } from './entities/StudentTagEntity'
import { migrations } from './migrations'
declare module '../../shared/kernel' {
interface Context {
db: DbManager
}
}
interface PostgreSQLConfig {
host: string
port: number
username: string
password: string
database: string
ssl?: boolean
sslmode?: string
channelBinding?: string
}
interface QueueItem {
id: string
operation: () => Promise<any>
resolve: (value: any) => void
reject: (error: Error) => void
timestamp: number
}
export function parsePostgreSQLConnectionString(connectionString: string): PostgreSQLConfig | null {
if (!connectionString.startsWith('postgresql://')) {
return null
}
try {
const url = new URL(connectionString)
const config: PostgreSQLConfig = {
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 5432,
username: url.username,
password: decodeURIComponent(url.password || ''),
database: url.pathname.slice(1),
ssl: false
}
const params = new URLSearchParams(url.search)
if (params.get('sslmode')) {
config.ssl = true
config.sslmode = params.get('sslmode') || 'require'
}
if (params.get('channel_binding')) {
config.channelBinding = params.get('channel_binding') || 'require'
}
return config
} catch (e) {
console.error('Failed to parse PostgreSQL connection string:', e)
return null
}
}
export class DbManager extends Service {
private _dataSource: DataSource | null = null
private _isPostgreSQL: boolean = false
private dbPath: string
private operationQueue: QueueItem[] = []
private isProcessingQueue: boolean = false
private queueIdCounter: number = 0
constructor(ctx: Context, dbPath: string, pgConnectionString?: string) {
super(ctx, 'db')
this.dbPath = dbPath
this._isPostgreSQL = !!pgConnectionString
}
get dataSource(): DataSource {
if (!this._dataSource) {
throw new Error('Database not initialized. Call initialize() first.')
}
return this._dataSource
}
get isPostgreSQL(): boolean {
return this._isPostgreSQL
}
private createDataSource(pgConnectionString?: string): DataSource {
const pgConfig = pgConnectionString ? parsePostgreSQLConnectionString(pgConnectionString) : null
if (pgConfig) {
this._isPostgreSQL = true
return new DataSource({
type: 'postgres',
host: pgConfig.host,
port: pgConfig.port,
username: pgConfig.username,
password: pgConfig.password,
database: pgConfig.database,
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : false,
extra: {
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : undefined,
sslmode: pgConfig.sslmode,
channelBinding: pgConfig.channelBinding
},
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false,
poolSize: 10,
connectTimeoutMS: 30000
})
} else {
this._isPostgreSQL = false
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
return new DataSource({
type: 'better-sqlite3',
database: this.dbPath,
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false
})
}
}
async initialize(pgConnectionString?: string) {
if (this._dataSource?.isInitialized) {
await this.dispose()
}
this._dataSource = this.createDataSource(pgConnectionString)
await this._dataSource.initialize()
if (!this._isPostgreSQL) {
await this._dataSource.query('PRAGMA foreign_keys = ON')
}
await this._dataSource.runMigrations()
}
async dispose() {
if (!this._dataSource?.isInitialized) return
await this._dataSource.destroy()
this._dataSource = null
}
async switchConnection(pgConnectionString?: string): Promise<{ type: 'sqlite' | 'postgresql' }> {
await this.dispose()
await this.initialize(pgConnectionString)
return { type: this._isPostgreSQL ? 'postgresql' : 'sqlite' }
}
getDatabaseType(): 'sqlite' | 'postgresql' {
return this._isPostgreSQL ? 'postgresql' : 'sqlite'
}
async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const item: QueueItem = {
id: `op-${++this.queueIdCounter}`,
operation,
resolve: resolve as (value: any) => void,
reject,
timestamp: Date.now()
}
this.operationQueue.push(item)
this.processQueue()
})
}
private async processQueue() {
if (this.isProcessingQueue || this.operationQueue.length === 0) {
return
}
this.isProcessingQueue = true
while (this.operationQueue.length > 0) {
const item = this.operationQueue.shift()!
try {
const result = await item.operation()
item.resolve(result)
} catch (error) {
item.reject(error as Error)
}
}
this.isProcessingQueue = false
}
async withQueue<T>(operation: () => Promise<T>): Promise<T> {
if (this._isPostgreSQL) {
return this.enqueueOperation(operation)
}
return operation()
}
async syncToRemote(): Promise<{ success: boolean; message?: string }> {
if (!this._isPostgreSQL) {
return { success: false, message: '当前不是远程数据库模式' }
}
if (!this._dataSource?.isInitialized) {
return { success: false, message: '数据库未初始化' }
}
try {
return await this.enqueueOperation(async () => {
await this._dataSource!.query('SELECT 1')
return { success: true, message: '同步成功' }
})
} catch (error: any) {
return { success: false, message: error?.message || '同步失败' }
}
}
}
@@ -1,170 +0,0 @@
import type { DataSource } from 'typeorm'
import {
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
StudentEntity
} from '../entities'
type exportBundle = {
students: StudentEntity[]
reasons: ReasonEntity[]
events: ScoreEventEntity[]
settlements: SettlementEntity[]
settings: SettingEntity[]
}
type importResult = { success: true } | { success: false; message: string }
export class DataBackupRepository {
constructor(private readonly dataSource: DataSource) {}
async exportJson(): Promise<string> {
const bundle = await this.exportBundle()
return JSON.stringify(bundle, null, 2)
}
private async exportBundle(): Promise<exportBundle> {
const students = await this.dataSource.getRepository(StudentEntity).find()
const reasons = await this.dataSource.getRepository(ReasonEntity).find()
const events = await this.dataSource.getRepository(ScoreEventEntity).find()
const settlements = await this.dataSource
.getRepository(SettlementEntity)
.find({ order: { id: 'ASC' } })
const settings = await this.dataSource
.getRepository(SettingEntity)
.createQueryBuilder('s')
.where("s.key NOT LIKE 'security_%'")
.getMany()
return { students, reasons, events, settlements, settings }
}
async importJson(jsonText: string): Promise<importResult> {
let parsed: any
try {
parsed = JSON.parse(String(jsonText ?? ''))
} catch {
return { success: false, message: 'Invalid JSON' }
}
const students = Array.isArray(parsed?.students) ? parsed.students : []
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
const events = Array.isArray(parsed?.events) ? parsed.events : []
const settlements = Array.isArray(parsed?.settlements) ? parsed.settlements : []
const settings = Array.isArray(parsed?.settings) ? parsed.settings : []
try {
await this.dataSource.transaction(async (manager) => {
await manager.clear(ScoreEventEntity)
await manager.clear(SettlementEntity)
await manager.clear(StudentEntity)
await manager.clear(ReasonEntity)
await manager
.getRepository(SettingEntity)
.createQueryBuilder()
.delete()
.where("key NOT LIKE 'security_%'")
.execute()
const insertStudents: Partial<StudentEntity>[] = []
for (const s of students) {
const name = String(s?.name ?? '').trim()
if (!name) continue
const score = Number(s?.score ?? 0)
const extraJson = s?.extra_json != null ? String(s.extra_json) : null
const createdAt = s?.created_at != null ? String(s.created_at) : new Date().toISOString()
const updatedAt = s?.updated_at != null ? String(s.updated_at) : new Date().toISOString()
insertStudents.push({
name,
score: Number.isFinite(score) ? score : 0,
extra_json: extraJson,
created_at: createdAt,
updated_at: updatedAt
})
}
if (insertStudents.length) {
await manager.getRepository(StudentEntity).insert(insertStudents)
}
const insertReasons: Partial<ReasonEntity>[] = []
for (const r of reasons) {
const content = String(r?.content ?? '').trim()
if (!content) continue
const category = String(r?.category ?? '其他')
const delta = Number(r?.delta ?? 0)
const isSystem = Number(r?.is_system ?? 0) ? 1 : 0
const updatedAt = r?.updated_at != null ? String(r.updated_at) : new Date().toISOString()
insertReasons.push({
content,
category,
delta: Number.isFinite(delta) ? delta : 0,
is_system: isSystem,
updated_at: updatedAt
})
}
if (insertReasons.length) {
await manager.getRepository(ReasonEntity).insert(insertReasons)
}
const insertSettlements: Partial<SettlementEntity>[] = []
for (const s of settlements) {
const id = Number(s?.id)
const startTime = String(s?.start_time ?? '').trim()
const endTime = String(s?.end_time ?? '').trim()
const createdAt = String(s?.created_at ?? new Date().toISOString())
if (!Number.isFinite(id) || !startTime || !endTime) continue
insertSettlements.push({
id,
start_time: startTime,
end_time: endTime,
created_at: createdAt
})
}
if (insertSettlements.length) {
await manager.getRepository(SettlementEntity).insert(insertSettlements)
}
const insertEvents: Partial<ScoreEventEntity>[] = []
for (const e of events) {
const uuid = String(e?.uuid ?? '').trim()
const studentName = String(e?.student_name ?? '').trim()
const reasonContent = String(e?.reason_content ?? '').trim()
if (!uuid || !studentName || !reasonContent) continue
const delta = Number(e?.delta ?? 0)
const valPrev = Number(e?.val_prev ?? 0)
const valCurr = Number(e?.val_curr ?? 0)
const eventTime = String(e?.event_time ?? new Date().toISOString())
const settlementIdRaw = e?.settlement_id
const settlementId =
settlementIdRaw === null || settlementIdRaw === undefined
? null
: Number(settlementIdRaw)
insertEvents.push({
uuid,
student_name: studentName,
reason_content: reasonContent,
delta: Number.isFinite(delta) ? delta : 0,
val_prev: Number.isFinite(valPrev) ? valPrev : 0,
val_curr: Number.isFinite(valCurr) ? valCurr : 0,
event_time: eventTime,
settlement_id: Number.isFinite(settlementId as any) ? (settlementId as any) : null
})
}
if (insertEvents.length) {
await manager.getRepository(ScoreEventEntity).insert(insertEvents)
}
for (const it of settings) {
const key = String(it?.key ?? '').trim()
if (!key || key.startsWith('security_')) continue
await manager.getRepository(SettingEntity).save({ key, value: String(it?.value ?? '') })
}
})
} catch (e: any) {
return { success: false, message: e?.message || 'Import failed' }
}
return { success: true }
}
}
-5
View File
@@ -1,5 +0,0 @@
export { StudentEntity } from './entities/StudentEntity'
export { ReasonEntity } from './entities/ReasonEntity'
export { ScoreEventEntity } from './entities/ScoreEventEntity'
export { SettlementEntity } from './entities/SettlementEntity'
export { SettingEntity } from './entities/SettingEntity'
@@ -1,23 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'reasons' })
export class ReasonEntity {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ type: 'text' })
content!: string
@Column({ type: 'text', default: '其他' })
category!: string
@Column({ type: 'integer' })
delta!: number
@Column({ type: 'integer', default: 0 })
is_system!: number
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
}
@@ -1,33 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'score_events' })
export class ScoreEventEntity {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ type: 'text' })
uuid!: string
@Column({ type: 'text' })
student_name!: string
@Column({ type: 'text' })
reason_content!: string
@Column({ type: 'integer' })
delta!: number
@Column({ type: 'integer' })
val_prev!: number
@Column({ type: 'integer' })
val_curr!: number
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
event_time!: string
@Index()
@Column({ type: 'integer', nullable: true })
settlement_id!: number | null
}
@@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from 'typeorm'
@Entity({ name: 'settings' })
export class SettingEntity {
@PrimaryColumn({ type: 'text' })
key!: string
@Column({ type: 'text', nullable: true })
value!: string | null
}
@@ -1,16 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'settlements' })
export class SettlementEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text' })
start_time!: string
@Column({ type: 'text' })
end_time!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
}
@@ -1,34 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm'
import { TagEntity } from './TagEntity'
@Entity({ name: 'students' })
export class StudentEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text' })
name!: string
@Column({ type: 'text', default: '[]' })
tags!: string
@Column({ type: 'integer', default: 0 })
score!: number
@Column({ type: 'text', nullable: true })
extra_json!: string | null
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
@ManyToMany(() => TagEntity, { cascade: true })
@JoinTable({
name: 'student_tags',
joinColumn: { name: 'student_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' }
})
tagEntities!: TagEntity[]
}
@@ -1,27 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'
import { StudentEntity } from './StudentEntity'
import { TagEntity } from './TagEntity'
@Entity({ name: 'student_tags' })
@Index(['student_id', 'tag_id'], { unique: true })
export class StudentTagEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ name: 'student_id', type: 'integer' })
student_id!: number
@Column({ name: 'tag_id', type: 'integer' })
tag_id!: number
@ManyToOne(() => StudentEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' })
student!: StudentEntity
@ManyToOne(() => TagEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tag_id' })
tag!: TagEntity
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
}
-16
View File
@@ -1,16 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'tags' })
export class TagEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text', unique: true })
name!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
}
-7
View File
@@ -1,7 +0,0 @@
export { StudentEntity } from './StudentEntity'
export { ReasonEntity } from './ReasonEntity'
export { ScoreEventEntity } from './ScoreEventEntity'
export { SettlementEntity } from './SettlementEntity'
export { SettingEntity } from './SettingEntity'
export { TagEntity } from './TagEntity'
export { StudentTagEntity } from './StudentTagEntity'
@@ -1,155 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm'
export class InitSchema2026011800000 implements MigrationInterface {
name = 'InitSchema2026011800000'
private isPostgres(queryRunner: QueryRunner): boolean {
return queryRunner.connection.options.type === 'postgres'
}
async up(queryRunner: QueryRunner): Promise<void> {
const isPg = this.isPostgres(queryRunner)
const pkSerial = isPg ? 'SERIAL PRIMARY KEY' : 'integer PRIMARY KEY AUTOINCREMENT'
const now = isPg ? 'CURRENT_TIMESTAMP' : '(CURRENT_TIMESTAMP)'
const defaultZero = isPg ? 'DEFAULT 0' : 'DEFAULT (0)'
const defaultEmptyArr = isPg ? "DEFAULT '[]'" : "DEFAULT '[]'"
if (!(await queryRunner.hasTable('students'))) {
await queryRunner.query(`
CREATE TABLE "students" (
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL,
"score" integer NOT NULL ${defaultZero},
"extra_json" text,
"tags" text NOT NULL ${defaultEmptyArr},
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('reasons'))) {
await queryRunner.query(`
CREATE TABLE "reasons" (
"id" ${pkSerial} NOT NULL,
"content" text NOT NULL,
"category" text NOT NULL DEFAULT '其他',
"delta" integer NOT NULL,
"is_system" integer NOT NULL ${defaultZero},
"updated_at" text NOT NULL DEFAULT ${now},
CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
)
`)
}
if (!(await queryRunner.hasTable('settlements'))) {
await queryRunner.query(`
CREATE TABLE "settlements" (
"id" ${pkSerial} NOT NULL,
"start_time" text NOT NULL,
"end_time" text NOT NULL,
"created_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('score_events'))) {
await queryRunner.query(`
CREATE TABLE "score_events" (
"id" ${pkSerial} NOT NULL,
"uuid" text NOT NULL,
"student_name" text NOT NULL,
"reason_content" text NOT NULL,
"delta" integer NOT NULL,
"val_prev" integer NOT NULL,
"val_curr" integer NOT NULL,
"event_time" text NOT NULL DEFAULT ${now},
"settlement_id" integer,
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
)
`)
}
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_score_events_settlement_id" ON "score_events" ("settlement_id")`
)
if (!(await queryRunner.hasTable('settings'))) {
await queryRunner.query(`
CREATE TABLE "settings" (
"key" text PRIMARY KEY NOT NULL,
"value" text
)
`)
}
const reasonValues = `('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP),('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP),('纪律良好','纪律',1,1,CURRENT_TIMESTAMP),('帮助同学','品德',2,1,CURRENT_TIMESTAMP),('迟到','纪律',-1,1,CURRENT_TIMESTAMP),('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP)`
if (isPg) {
await queryRunner.query(`
INSERT INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
${reasonValues}
ON CONFLICT DO NOTHING
`)
} else {
await queryRunner.query(`
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
${reasonValues}
`)
}
if (!(await queryRunner.hasTable('tags'))) {
await queryRunner.query(`
CREATE TABLE "tags" (
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL UNIQUE,
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('student_tags'))) {
await queryRunner.query(`
CREATE TABLE "student_tags" (
"id" ${pkSerial} NOT NULL,
"student_id" integer NOT NULL,
"tag_id" integer NOT NULL,
"created_at" text NOT NULL DEFAULT ${now},
FOREIGN KEY ("student_id") REFERENCES "students"("id") ON DELETE CASCADE,
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE,
UNIQUE("student_id", "tag_id")
)
`)
}
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_student_id" ON "student_tags" ("student_id")`
)
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
)
const tagValues = `('优秀生'),('班干部'),('勤奋'),('活跃'),('进步快')`
if (isPg) {
await queryRunner.query(`
INSERT INTO "tags" ("name") VALUES ${tagValues}
ON CONFLICT DO NOTHING
`)
} else {
await queryRunner.query(`
INSERT OR IGNORE INTO "tags" ("name") VALUES ${tagValues}
`)
}
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "score_events"`)
await queryRunner.query(`DROP TABLE IF EXISTS "settlements"`)
await queryRunner.query(`DROP TABLE IF EXISTS "students"`)
await queryRunner.query(`DROP TABLE IF EXISTS "reasons"`)
await queryRunner.query(`DROP TABLE IF EXISTS "settings"`)
await queryRunner.query(`DROP TABLE IF EXISTS "student_tags"`)
await queryRunner.query(`DROP TABLE IF EXISTS "tags"`)
}
}
-3
View File
@@ -1,3 +0,0 @@
import { InitSchema2026011800000 } from './InitSchema2026011800000'
export const migrations = [InitSchema2026011800000]
@@ -1,92 +0,0 @@
import type {
configureHostDelegate,
hostApplicationContext,
hostBuilderContext,
hostedService,
middleware,
serviceToken
} from './types'
import { ServiceProvider } from './serviceCollection'
export class HostApplication {
private hostedInstances: hostedService[] = []
private started = false
constructor(
private readonly context: hostBuilderContext,
private readonly provider: ServiceProvider,
private readonly configureDelegates: configureHostDelegate[],
private readonly middleware: middleware[],
private readonly hostedTokens: serviceToken<hostedService>[]
) {}
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
}
get hostContext(): hostBuilderContext {
return this.context
}
private createApplicationContext(): hostApplicationContext {
return { 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)
await service.start()
}
}
private async disposeHostedServices() {
while (this.hostedInstances.length) {
const service = this.hostedInstances.pop()
if (!service) continue
await service.stop()
}
}
private async dispatch(
index: number,
appCtx: hostApplicationContext,
terminal: () => Promise<void>
) {
const middleware = this.middleware[index]
if (!middleware) {
await terminal()
return
}
await middleware(appCtx, () => this.dispatch(index + 1, appCtx, terminal))
}
}
-110
View File
@@ -1,110 +0,0 @@
import { ServiceCollection } from './serviceCollection'
import { HostApplication } from './hostApplication'
import {
type appRuntimeContext,
type configureHostDelegate,
type configureServicesDelegate,
type hostBuilderContext,
type hostBuilderSettings,
type hostedService,
type middleware,
type serviceToken
} from './types'
export class SecScoreHostBuilder {
private readonly serviceCollection: ServiceCollection
private readonly configureServicesDelegates: configureServicesDelegate[] = []
private readonly configureDelegates: configureHostDelegate[] = []
private readonly middleware: middleware[] = []
private readonly hostedServices: serviceToken<hostedService>[] = []
private readonly builderContext: hostBuilderContext
constructor(runtimeCtx: appRuntimeContext, settings: hostBuilderSettings = {}) {
this.serviceCollection = new ServiceCollection(runtimeCtx)
this.builderContext = {
ctx: runtimeCtx,
environmentName: settings.environment ?? process.env.SECSCORE_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: middleware): this {
this.middleware.push(middleware)
return this
}
addHostedService(token: serviceToken<hostedService>): this {
this.hostedServices.push(token)
return this
}
async build(): Promise<SecScoreHost> {
for (const configure of this.configureServicesDelegates) {
await configure(this.builderContext, this.serviceCollection)
}
const provider = this.serviceCollection.buildServiceProvider()
const application = new HostApplication(
this.builderContext,
provider,
this.configureDelegates,
this.middleware,
this.hostedServices
)
return new SecScoreHost(application)
}
}
export class SecScoreHost {
constructor(private readonly app: HostApplication) {}
get services() {
return this.app.services
}
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()
}
}
}
export function createHostBuilder(ctx: appRuntimeContext, settings?: hostBuilderSettings) {
return new SecScoreHostBuilder(ctx, settings)
}
export const Host = {
createApplicationBuilder: createHostBuilder
}
-32
View File
@@ -1,32 +0,0 @@
export { SecScoreHostBuilder, SecScoreHost, createHostBuilder, Host } from './hostBuilder'
export { ServiceCollection, ServiceProvider } from './serviceCollection'
export {
HostApplicationLifetimeToken,
AppConfigToken,
LoggerToken,
DbManagerToken,
SettingsStoreToken,
SecurityServiceToken,
PermissionServiceToken,
StudentRepositoryToken,
ReasonRepositoryToken,
EventRepositoryToken,
SettlementRepositoryToken,
TagRepositoryToken,
ThemeServiceToken,
WindowManagerToken,
TrayServiceToken,
AutoScoreServiceToken
} from './tokens'
export type {
appRuntimeContext,
hostedService,
middleware,
hostBuilderSettings,
hostBuilderContext,
configureServicesDelegate,
configureHostDelegate,
hostApplicationLifetime,
hostApplicationContext,
serviceToken
} from './types'
-61
View File
@@ -1,61 +0,0 @@
import type { awaitable, disposer, hostApplicationLifetime } from './types'
type loggerLike = { error: (...args: any[]) => void }
// 默认的应用生命周期实现,管理启动/停止事件
export class DefaultHostApplicationLifetime implements hostApplicationLifetime {
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('[HostLifetime] handler failed', error as Error) // 记录错误但不中断
}
}
}
}
@@ -1,216 +0,0 @@
import { HostApplicationLifetimeToken } from './tokens'
import { DefaultHostApplicationLifetime } from './lifetime'
import type {
appRuntimeContext,
disposer,
injectableClass,
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: appRuntimeContext) {
this.hostLifetime = new DefaultHostApplicationLifetime(this.ctx.logger ?? console)
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: appRuntimeContext,
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) {
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)
}
// 提取实例的清理函数
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 @@
export const HostApplicationLifetimeToken = Symbol.for('secscore.hosting.lifetime')
export const AppConfigToken = Symbol.for('secscore.app.config')
export const LoggerToken = Symbol.for('secscore.logger')
export const DbManagerToken = Symbol.for('secscore.dbManager')
export const SettingsStoreToken = Symbol.for('secscore.settingsStore')
export const SecurityServiceToken = Symbol.for('secscore.securityService')
export const PermissionServiceToken = Symbol.for('secscore.permissionService')
export const StudentRepositoryToken = Symbol.for('secscore.studentRepository')
export const ReasonRepositoryToken = Symbol.for('secscore.reasonRepository')
export const EventRepositoryToken = Symbol.for('secscore.eventRepository')
export const SettlementRepositoryToken = Symbol.for('secscore.settlementRepository')
export const TagRepositoryToken = Symbol.for('secscore.tagRepository')
export const ThemeServiceToken = Symbol.for('secscore.themeService')
export const WindowManagerToken = Symbol.for('secscore.windowManager')
export const TrayServiceToken = Symbol.for('secscore.trayService')
export const AutoScoreServiceToken = Symbol.for('secscore.autoScoreService')
-71
View File
@@ -1,71 +0,0 @@
import type { ServiceCollection, ServiceProvider } from './serviceCollection'
export type awaitable<T> = T | Promise<T>
export type disposer = () => awaitable<void>
export type serviceToken<T = unknown> = string | symbol | (new (...args: any[]) => T)
export interface injectableClass<T = unknown> {
new (...args: any[]): T
inject?: readonly serviceToken[]
}
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>
}
export interface hostBuilderSettings {
environment?: string
properties?: Record<string, unknown>
}
export interface hostApplicationLifetime {
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 interface appRuntimeContext {
logger?: { error: (...args: any[]) => void }
}
export interface hostBuilderContext {
ctx: appRuntimeContext
environmentName: string
properties: Map<string | symbol, unknown>
lifetime: hostApplicationLifetime
}
export interface hostApplicationContext {
services: ServiceProvider
host: hostBuilderContext
}
export type configureServicesDelegate = (
context: hostBuilderContext,
services: ServiceCollection
) => awaitable<void>
export type configureHostDelegate = (
context: hostBuilderContext,
app: hostApplicationContext
) => awaitable<void>
export type middleware = (app: hostApplicationContext, next: () => Promise<void>) => awaitable<void>
export type { ServiceCollection, ServiceProvider }
-367
View File
@@ -1,367 +0,0 @@
import 'reflect-metadata'
import { app } from 'electron'
import { join, dirname } from 'path'
import fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/SecScore_logo.ico?asset'
import { MainContext } from './context'
import { DbManager } from './db/DbManager'
import { LoggerService } from './services/LoggerService'
import { SettingsService } from './services/SettingsService'
import { SecurityService } from './services/SecurityService'
import { PermissionService } from './services/PermissionService'
import { AuthService } from './services/AuthService'
import { DataService } from './services/DataService'
import { ThemeService } from './services/ThemeService'
import { WindowManager, type windowManagerOptions } from './services/WindowManager'
import { TrayService } from './services/TrayService'
import { AutoScoreService } from './services/AutoScoreService'
import { DbConnectionService } from './services/DbConnectionService'
import { StudentRepository } from './repos/StudentRepository'
import { ReasonRepository } from './repos/ReasonRepository'
import { EventRepository } from './repos/EventRepository'
import { SettlementRepository } from './repos/SettlementRepository'
import { TagRepository } from './repos/TagRepository'
import {
AppConfigToken,
createHostBuilder,
DbManagerToken,
EventRepositoryToken,
LoggerToken,
PermissionServiceToken,
ReasonRepositoryToken,
SecurityServiceToken,
SettlementRepositoryToken,
SettingsStoreToken,
StudentRepositoryToken,
TagRepositoryToken,
ThemeServiceToken,
WindowManagerToken,
TrayServiceToken,
AutoScoreServiceToken
} from './hosting'
type mainAppConfig = {
isDev: boolean
appRoot: string
dataRoot: string
configDir: string
logDir: string
dbPath: string
pgConnectionString?: string
window: windowManagerOptions
}
const PROTOCOL_SCHEME = 'secscore'
let mainCtxRef: MainContext | null = null
let pendingProtocolUrl: string | null = null
const extractProtocolUrl = (argv: string[]): string | null => {
const prefix = `${PROTOCOL_SCHEME}://`
const lowerPrefix = prefix.toLowerCase()
for (const arg of argv) {
if (typeof arg !== 'string') continue
const v = arg.trim()
if (!v) continue
const lower = v.toLowerCase()
if (lower.startsWith(lowerPrefix)) return v
}
return null
}
const openMainRoute = (ctx: MainContext, route: string) => {
ctx.windows.open({
key: 'main',
title: 'SecScore',
route
})
}
const handleProtocolUrl = (rawUrl: string, ctx: MainContext) => {
if (!rawUrl) return
let s = rawUrl.trim()
if (!s) return
const prefix = `${PROTOCOL_SCHEME}://`
if (s.toLowerCase().startsWith(prefix)) {
s = s.slice(prefix.length)
}
s = s.replace(/^\/+/, '')
if (!s) {
openMainRoute(ctx, '/')
return
}
const parts = s.split('/')
const head = parts[0]?.toLowerCase() ?? ''
if (!head) {
openMainRoute(ctx, '/')
return
}
if (head === 'home') {
openMainRoute(ctx, '/')
return
}
if (head === 'students') {
openMainRoute(ctx, '/students')
return
}
if (head === 'score') {
openMainRoute(ctx, '/score')
return
}
if (head === 'leaderboard') {
openMainRoute(ctx, '/leaderboard')
return
}
if (head === 'settlements') {
openMainRoute(ctx, '/settlements')
return
}
if (head === 'reasons') {
openMainRoute(ctx, '/reasons')
return
}
if (head === 'settings') {
openMainRoute(ctx, '/settings')
return
}
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
} else {
const initialUrl = extractProtocolUrl(process.argv)
if (initialUrl) {
pendingProtocolUrl = initialUrl
}
app.on('second-instance', (event, argv) => {
event.preventDefault()
const url = extractProtocolUrl(argv)
if (!url) return
if (mainCtxRef) {
handleProtocolUrl(url, mainCtxRef)
} else {
pendingProtocolUrl = url
}
})
app.on('open-url', (event, url) => {
event.preventDefault()
if (mainCtxRef) {
handleProtocolUrl(url, mainCtxRef)
} else {
pendingProtocolUrl = url
}
})
}
app.whenReady().then(async () => {
electronApp.setAppUserModelId('com.electron')
if (!is.dev) {
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
const appRoot = is.dev ? process.cwd() : dirname(process.execPath)
const ensureWritableDir = (preferred: string, fallback: string) => {
try {
if (!fs.existsSync(preferred)) fs.mkdirSync(preferred, { recursive: true })
return preferred
} catch {
if (!fs.existsSync(fallback)) fs.mkdirSync(fallback, { recursive: true })
return fallback
}
}
const dataRoot = is.dev
? process.cwd()
: ensureWritableDir(join(appRoot, 'data'), join(app.getPath('userData'), 'secscore-data'))
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
const configDir = is.dev ? join(process.cwd(), 'configs') : join(dataRoot, 'configs')
const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite')
const pgConnectionString = process.env['PG_CONNECTION_STRING'] || undefined
const config: mainAppConfig = {
isDev: is.dev,
appRoot,
dataRoot,
logDir,
configDir,
dbPath,
pgConnectionString,
window: {
icon,
preloadPath: join(__dirname, '../preload/index.js'),
rendererHtmlPath: join(__dirname, '../renderer/index.html'),
getRendererUrl: () => (is.dev ? process.env['ELECTRON_RENDERER_URL'] : undefined)
}
}
const builder = createHostBuilder({
logger: {
error: (...args: any[]) => {
try {
process.stderr.write(`${args.map((a) => String(a)).join(' ')}\n`)
} catch {
return
}
}
}
})
.configureServices(async (_builderContext, services) => {
services.addSingleton(AppConfigToken, config)
services.addSingleton(MainContext, () => new MainContext())
services.addSingleton(
LoggerToken,
(p) => new LoggerService(p.get(MainContext), config.logDir)
)
services.addSingleton(
DbManagerToken,
(p) => new DbManager(p.get(MainContext), config.dbPath, config.pgConnectionString)
)
services.addSingleton(SettingsStoreToken, (p) => new SettingsService(p.get(MainContext)))
services.addSingleton(SecurityServiceToken, (p) => new SecurityService(p.get(MainContext)))
services.addSingleton(
PermissionServiceToken,
(p) => new PermissionService(p.get(MainContext))
)
services.addSingleton(AuthService, (p) => new AuthService(p.get(MainContext)))
services.addSingleton(
DataService,
(p) => new DataService(p.get(MainContext), p.get(TagRepositoryToken))
)
services.addSingleton(
StudentRepositoryToken,
(p) => new StudentRepository(p.get(MainContext))
)
services.addSingleton(ReasonRepositoryToken, (p) => new ReasonRepository(p.get(MainContext)))
services.addSingleton(EventRepositoryToken, (p) => new EventRepository(p.get(MainContext)))
services.addSingleton(
SettlementRepositoryToken,
(p) => new SettlementRepository(p.get(MainContext))
)
services.addSingleton(
TagRepositoryToken,
(p) => new TagRepository((p.get(DbManagerToken) as DbManager).dataSource)
)
services.addSingleton(ThemeServiceToken, (p) => new ThemeService(p.get(MainContext)))
services.addSingleton(
WindowManagerToken,
(p) => new WindowManager(p.get(MainContext), config.window)
)
services.addSingleton(
TrayServiceToken,
(p) => new TrayService(p.get(MainContext), config.window)
)
services.addSingleton(AutoScoreServiceToken, (p) => new AutoScoreService(p.get(MainContext)))
services.addSingleton(DbConnectionService, (p) => new DbConnectionService(p.get(MainContext)))
})
.configure(async (_builderContext, appCtx) => {
const services = appCtx.services
services.get(LoggerToken)
// 先初始化 db(使用默认 SQLite)
const db = services.get(DbManagerToken) as DbManager
await db.initialize()
// 然后初始化 settings
const settings = services.get(SettingsStoreToken) as SettingsService
await settings.initialize()
// 检查是否需要切换到 PostgreSQL
const pgConnectionString = settings.getValue('pg_connection_string')
if (pgConnectionString) {
try {
await db.switchConnection(pgConnectionString)
await settings.setValue('pg_connection_status', {
connected: true,
type: 'postgresql'
})
} catch (e: any) {
console.error('Failed to connect to PostgreSQL:', e)
await settings.setValue('pg_connection_status', {
connected: false,
type: 'postgresql',
error: e?.message || '连接失败'
})
// 切换回 SQLite
await db.switchConnection(undefined)
}
}
services.get(SecurityServiceToken)
services.get(PermissionServiceToken)
services.get(AuthService)
services.get(DataService)
services.get(StudentRepositoryToken)
services.get(ReasonRepositoryToken)
services.get(EventRepositoryToken)
services.get(SettlementRepositoryToken)
const theme = services.get(
ThemeServiceToken
) as import('./services/ThemeService').ThemeService
await theme.init()
if (!process.env.HEADLESS) {
services.get(WindowManagerToken)
const tray = services.get(TrayServiceToken) as TrayService
tray.initialize()
}
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
await autoScore.initialize?.()
services.get(DbConnectionService)
})
const host = await builder.build()
const ctx = host.services.get(MainContext) as MainContext
mainCtxRef = ctx
ctx.handle('app:register-url-protocol', async () => {
if (is.dev) {
return { success: false, message: '仅在打包后的应用中可用' }
}
try {
const ok = app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
if (ok) {
return { success: true, data: { registered: true } }
}
return { success: false, data: { registered: false }, message: '系统未接受协议注册' }
} catch (error) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
return { success: false, message: `注册失败: ${message}` }
}
})
await host.start()
if (pendingProtocolUrl) {
handleProtocolUrl(pendingProtocolUrl, ctx)
pendingProtocolUrl = null
} else {
openMainRoute(ctx, '/')
}
let disposing = false
const beforeQuitHandler = () => {
if (disposing) return
disposing = true
ctx.isQuitting = true
app.removeListener('before-quit', beforeQuitHandler)
void host.dispose()
}
app.on('before-quit', beforeQuitHandler)
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
-248
View File
@@ -1,248 +0,0 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { v4 as uuidv4 } from 'uuid'
import { IsNull } from 'typeorm'
import { ScoreEventEntity, StudentEntity } from '../db/entities'
export interface scoreEvent {
id: number
uuid: string
student_name: string
reason_content: string
delta: number
val_prev: number
val_curr: number
event_time: string
settlement_id?: number | null
}
declare module '../../shared/kernel' {
interface Context {
events: EventRepository
}
}
export class EventRepository extends Service {
constructor(ctx: MainContext) {
super(ctx, 'events')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
this.mainCtx.handle('db:event:query', async (_, params) => {
try {
return { success: true, data: await this.findAll(params?.limit) }
} catch (err: any) {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('db:event:delete', async (event, uuid) => {
try {
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
return { success: false, message: 'Permission denied' }
await this.deleteByUuid(uuid)
return { success: true }
} catch (err: any) {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('db:event:create', async (event, data) => {
try {
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
return { success: false, message: 'Permission denied' }
const id = await this.create(data)
return { success: true, data: id }
} catch (e: any) {
return { success: false, message: e.message }
}
})
this.mainCtx.handle('db:event:queryByStudent', async (_, params) => {
try {
const limit = Number(params?.limit ?? 50)
const studentName = String(params?.student_name ?? '')
const startTime = params?.startTime ? String(params.startTime) : null
if (!studentName) return { success: true, data: [] }
const rows = await this.queryByStudent(studentName, startTime, limit)
return { success: true, data: rows }
} catch (err: any) {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('db:leaderboard:query', async (_, params) => {
try {
const range = String(params?.range ?? 'today')
const data = await this.queryLeaderboard(range)
return { success: true, data }
} catch (err: any) {
return { success: false, message: err.message }
}
})
}
async findAll(limit = 100) {
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
return await repo.find({
where: { settlement_id: IsNull() },
order: { event_time: 'DESC' },
take: limit
})
}
async create(event: { student_name: string; reason_content: string; delta: number }) {
const ds = this.ctx.db.dataSource
return await ds.transaction(async (manager) => {
const studentName = String(event?.student_name ?? '').trim()
const reasonContent = String(event?.reason_content ?? '').trim()
const delta = Number(event?.delta ?? 0)
const studentRepo = manager.getRepository(StudentEntity)
const existingStudent = await studentRepo.findOne({ where: { name: studentName } })
if (!existingStudent) throw new Error('Student not found')
const val_prev = Number(existingStudent.score ?? 0)
const val_curr = val_prev + (Number.isFinite(delta) ? delta : 0)
const uuid = uuidv4()
const event_time = new Date().toISOString()
const eventsRepo = manager.getRepository(ScoreEventEntity)
const saved = await eventsRepo.save(
eventsRepo.create({
uuid,
student_name: studentName,
reason_content: reasonContent,
delta: Number.isFinite(delta) ? delta : 0,
val_prev,
val_curr,
event_time,
settlement_id: null
})
)
await studentRepo.update(
{ id: existingStudent.id },
{ score: val_curr, updated_at: new Date().toISOString() }
)
return saved.id
})
}
async deleteByUuid(uuid: string) {
const ds = this.ctx.db.dataSource
await ds.transaction(async (manager) => {
const eventsRepo = manager.getRepository(ScoreEventEntity)
const ev = await eventsRepo.findOne({ where: { uuid: String(uuid ?? '').trim() } })
if (!ev) return
if (ev.settlement_id !== null && ev.settlement_id !== undefined) {
throw new Error('该记录已结算,无法撤销')
}
const studentRepo = manager.getRepository(StudentEntity)
const student = await studentRepo.findOne({ where: { name: ev.student_name } })
if (student) {
await studentRepo.update(
{ id: student.id },
{
score: Number(student.score ?? 0) - Number(ev.delta ?? 0),
updated_at: new Date().toISOString()
}
)
}
await eventsRepo.delete({ uuid: ev.uuid })
})
}
private isPostgres(): boolean {
return this.ctx.db.dataSource.options.type === 'postgres'
}
async queryByStudent(studentName: string, startTime: string | null, limit: number) {
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
const qb = repo
.createQueryBuilder('e')
.where('e.student_name = :studentName', { studentName })
.andWhere('e.settlement_id IS NULL')
.orderBy('e.event_time', 'DESC')
.limit(limit)
if (startTime) {
if (this.isPostgres()) {
qb.andWhere('e.event_time >= :startTime', { startTime })
} else {
qb.andWhere('julianday(e.event_time) >= julianday(:startTime)', { startTime })
}
}
return await qb.getMany()
}
async queryLeaderboard(range: string) {
const now = new Date()
let start = new Date(now)
if (range === 'today') {
start.setHours(0, 0, 0, 0)
} else if (range === 'week') {
const day = start.getDay()
const diff = (day === 0 ? -6 : 1) - day
start.setDate(start.getDate() + diff)
start.setHours(0, 0, 0, 0)
} else if (range === 'month') {
start = new Date(start.getFullYear(), start.getMonth(), 1)
start.setHours(0, 0, 0, 0)
} else {
start.setHours(0, 0, 0, 0)
}
const startTime = start.toISOString()
const isPg = this.isPostgres()
const joinCondition = isPg
? 'e.student_name = s.name AND e.settlement_id IS NULL AND e.event_time >= :startTime'
: 'e.student_name = s.name AND e.settlement_id IS NULL AND julianday(e.event_time) >= julianday(:startTime)'
const qb = this.ctx.db.dataSource
.getRepository(StudentEntity)
.createQueryBuilder('s')
.leftJoin(ScoreEventEntity, 'e', joinCondition, { startTime })
.select('s.id', 'id')
.addSelect('s.name', 'name')
.addSelect('s.score', 'score')
.addSelect('COALESCE(SUM(e.delta), 0)', 'range_change')
.groupBy('s.id')
.addGroupBy('s.name')
.addGroupBy('s.score')
.orderBy('s.score', 'DESC')
.addOrderBy('range_change', 'DESC')
.addOrderBy('s.name', 'ASC')
const rows = await qb.getRawMany()
return { startTime, rows }
}
async getLastScoreTimeByStudents(studentNames: string[]): Promise<Map<string, Date>> {
if (studentNames.length === 0) return new Map()
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
const results = await repo
.createQueryBuilder('e')
.select('e.student_name', 'student_name')
.addSelect('MAX(e.event_time)', 'last_time')
.where('e.student_name IN (:...studentNames)', { studentNames })
.groupBy('e.student_name')
.getRawMany()
const map = new Map<string, Date>()
for (const row of results) {
if (row.last_time) {
map.set(row.student_name, new Date(row.last_time))
}
}
return map
}
}
-94
View File
@@ -1,94 +0,0 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { ReasonEntity } from '../db/entities'
export interface reason {
id: number
content: string
category: string
delta: number
is_system: number
}
declare module '../../shared/kernel' {
interface Context {
reasons: ReasonRepository
}
}
export class ReasonRepository extends Service {
constructor(ctx: MainContext) {
super(ctx, 'reasons')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
this.mainCtx.handle('db:reason:query', async () => ({
success: true,
data: await this.findAll()
}))
this.mainCtx.handle('db:reason:create', async (event, data) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
return { success: true, data: await this.create(data) }
})
this.mainCtx.handle('db:reason:update', async (event, id, data) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
await this.update(id, data)
return { success: true }
})
this.mainCtx.handle('db:reason:delete', async (event, id) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const changes = await this.delete(id)
if (!changes) return { success: false, message: '记录不存在' }
return { success: true, data: { changes } }
})
// 兼容前端 deleteReason 命名错误
this.mainCtx.handle('db:deleteReason', async (event, id) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const changes = await this.delete(id)
if (!changes) return { success: false, message: '记录不存在' }
return { success: true, data: { changes } }
})
}
async findAll(): Promise<reason[]> {
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
return (await repo.find({ order: { category: 'ASC', content: 'ASC' } })) as any
}
async create(reason: Omit<reason, 'id' | 'is_system'>): Promise<number> {
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
const created = repo.create({
content: String(reason?.content ?? '').trim(),
category: String(reason?.category ?? '其他'),
delta: Number(reason?.delta ?? 0),
is_system: 0,
updated_at: new Date().toISOString()
})
const saved = await repo.save(created)
return saved.id
}
async update(id: number, reason: Partial<reason>): Promise<void> {
const next: any = {}
for (const [key, val] of Object.entries(reason)) {
if (key === 'id') continue
next[key] = val
}
next.updated_at = new Date().toISOString()
await this.ctx.db.dataSource.getRepository(ReasonEntity).update(id, next)
}
async delete(id: number): Promise<number> {
const result = await this.ctx.db.dataSource.getRepository(ReasonEntity).delete(id)
return Number(result.affected ?? 0)
}
}
@@ -1,180 +0,0 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { IsNull } from 'typeorm'
import { ScoreEventEntity, SettlementEntity, StudentEntity } from '../db/entities'
function isPostgres(ds: any): boolean {
return ds.options?.type === 'postgres'
}
export interface settlementSummary {
id: number
start_time: string
end_time: string
event_count: number
}
export interface settlementLeaderboardRow {
name: string
score: number
}
declare module '../../shared/kernel' {
interface Context {
settlements: SettlementRepository
}
}
export class SettlementRepository extends Service {
constructor(ctx: MainContext) {
super(ctx, 'settlements')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
this.mainCtx.handle('db:settlement:query', async () => {
try {
return { success: true, data: await this.findAll() }
} catch (err: any) {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('db:settlement:create', async (event) => {
try {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const data = await this.settleNow()
return { success: true, data }
} catch (err: any) {
return { success: false, message: err.message }
}
})
this.mainCtx.handle('db:settlement:leaderboard', async (_, params) => {
try {
const settlementId = Number(params?.settlement_id)
if (!Number.isFinite(settlementId))
return { success: false, message: 'Invalid settlement_id' }
return { success: true, data: await this.getLeaderboard(settlementId) }
} catch (err: any) {
return { success: false, message: err.message }
}
})
}
async findAll(): Promise<settlementSummary[]> {
const ds = this.ctx.db.dataSource
const orderBy = isPostgres(ds) ? 's.end_time' : 'julianday(s.end_time)'
const qb = ds
.getRepository(SettlementEntity)
.createQueryBuilder('s')
.select('s.id', 'id')
.addSelect('s.start_time', 'start_time')
.addSelect('s.end_time', 'end_time')
.addSelect((subQb) => {
return subQb
.select('COUNT(1)', 'cnt')
.from(ScoreEventEntity, 'e')
.where('e.settlement_id = s.id')
}, 'event_count')
.orderBy(orderBy, 'DESC')
const rows = await qb.getRawMany()
return rows.map((r: any) => ({
id: Number(r.id),
start_time: String(r.start_time),
end_time: String(r.end_time),
event_count: Number(r.event_count ?? 0)
}))
}
async settleNow() {
const ds = this.ctx.db.dataSource
return await ds.transaction(async (manager) => {
const eventsRepo = manager.getRepository(ScoreEventEntity)
const unassignedCount = await eventsRepo.count({ where: { settlement_id: IsNull() } })
const eventCount = Number(unassignedCount ?? 0)
if (eventCount <= 0) {
throw new Error('暂无可结算记录')
}
const endTime = new Date().toISOString()
const settlementsRepo = manager.getRepository(SettlementEntity)
const ds = this.ctx.db.dataSource
const orderBy = isPostgres(ds) ? 's.end_time' : 'julianday(s.end_time)'
const lastSettlement = await settlementsRepo
.createQueryBuilder('s')
.select('s.end_time', 'end_time')
.orderBy(orderBy, 'DESC')
.limit(1)
.getRawOne<{ end_time?: string }>()
const minEvent = await eventsRepo
.createQueryBuilder('e')
.select('MIN(e.event_time)', 'min_time')
.where('e.settlement_id IS NULL')
.getRawOne<{ min_time?: string }>()
const startTime = String(lastSettlement?.end_time || minEvent?.min_time || endTime)
const created = await settlementsRepo.save(
settlementsRepo.create({
start_time: startTime,
end_time: endTime,
created_at: new Date().toISOString()
})
)
const settlementId = created.id
await eventsRepo
.createQueryBuilder()
.update(ScoreEventEntity)
.set({ settlement_id: settlementId })
.where('settlement_id IS NULL')
.execute()
await manager
.getRepository(StudentEntity)
.createQueryBuilder()
.update(StudentEntity)
.set({ score: 0, updated_at: new Date().toISOString() })
.execute()
return { settlementId, startTime, endTime, eventCount }
})
}
async getLeaderboard(settlementId: number) {
const settlementsRepo = this.ctx.db.dataSource.getRepository(SettlementEntity)
const settlement = await settlementsRepo.findOne({ where: { id: settlementId } })
if (!settlement) {
throw new Error('结算记录不存在')
}
const rows = await this.ctx.db.dataSource
.getRepository(ScoreEventEntity)
.createQueryBuilder('e')
.select('e.student_name', 'name')
.addSelect('COALESCE(SUM(e.delta), 0)', 'score')
.where('e.settlement_id = :settlementId', { settlementId })
.groupBy('e.student_name')
.orderBy('score', 'DESC')
.addOrderBy('name', 'ASC')
.getRawMany<settlementLeaderboardRow>()
return {
settlement: {
id: settlement.id,
start_time: settlement.start_time,
end_time: settlement.end_time
},
rows: rows.map((r: any) => ({ name: String(r.name), score: Number(r.score ?? 0) }))
}
}
}
-184
View File
@@ -1,184 +0,0 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { ScoreEventEntity, StudentEntity } from '../db/entities'
export interface student {
id: number
name: string
score: number
tags?: string[]
extra_json?: string
}
declare module '../../shared/kernel' {
interface Context {
students: StudentRepository
}
}
export class StudentRepository extends Service {
constructor(ctx: MainContext) {
super(ctx, 'students')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
this.mainCtx.handle('db:student:query', async () => ({
success: true,
data: await this.findAll()
}))
this.mainCtx.handle('db:student:create', async (event, data) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
return { success: true, data: await this.create(data) }
})
this.mainCtx.handle('db:student:update', async (event, id, data) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
await this.update(id, data)
return { success: true }
})
this.mainCtx.handle('db:student:delete', async (event, id) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
await this.delete(id)
return { success: true }
})
this.mainCtx.handle('db:student:importFromSecRandom', async (event) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
return {
success: false,
message: 'SecRandom IPC 导入已禁用(后续再做)'
}
})
this.mainCtx.handle('db:student:importFromXlsx', async (event, input: any) => {
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const rawNames = Array.isArray(input?.names) ? input.names : []
const names = rawNames.map((n: any) => String(n ?? '').trim()).filter((n: string) => n)
if (!names.length) return { success: false, message: '名单为空' }
const result = await this.importRosterMerge(
names.map((name) => ({ name, secrandomId: null }))
)
return { success: true, data: result }
})
}
async findAll(): Promise<student[]> {
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
const entities = await repo.find({
select: ['id', 'name', 'score', 'tags', 'extra_json', 'created_at', 'updated_at'],
order: { score: 'DESC', name: 'ASC' }
})
return entities.map((entity) => {
let tags: string[] = []
try {
tags = Array.isArray(entity.tags) ? entity.tags : JSON.parse(entity.tags || '[]')
} catch {
tags = []
}
return {
id: entity.id,
name: entity.name,
score: entity.score,
tags,
extra_json: entity.extra_json ?? undefined
}
})
}
async create(student: { name: string }): Promise<number> {
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
const created = repo.create({
name: String(student?.name ?? '').trim(),
score: 0,
extra_json: null,
tags: '[]',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
const saved = await repo.save(created)
return saved.id
}
async update(id: number, student: Partial<student>): Promise<void> {
const next: any = {}
for (const [key, val] of Object.entries(student)) {
if (key === 'id') continue
if (key === 'tags') {
next[key] = JSON.stringify(val || [])
} else {
next[key] = val
}
}
next.updated_at = new Date().toISOString()
await this.ctx.db.dataSource.getRepository(StudentEntity).update(id, next)
}
async delete(id: number): Promise<void> {
const ds = this.ctx.db.dataSource
await ds.transaction(async (manager) => {
const studentsRepo = manager.getRepository(StudentEntity)
const studentRow = await studentsRepo.findOne({ where: { id } })
if (!studentRow) return
await manager.getRepository(ScoreEventEntity).delete({ student_name: studentRow.name })
await studentsRepo.delete({ id })
})
}
private async importRosterMerge(items: Array<{ name: string; secrandomId: number | null }>) {
const cleaned = Array.from(
new Map(
items
.map((i) => ({
name: String(i?.name ?? '').trim(),
secrandomId: Number.isFinite(i?.secrandomId as any) ? Number(i.secrandomId) : null
}))
.filter((i) => i.name && i.name.length <= 64)
.map((i) => [i.name, i] as const)
).values()
)
if (!cleaned.length) return { inserted: 0, skipped: 0, total: 0 }
const ds = this.ctx.db.dataSource
return await ds.transaction(async (manager) => {
const repo = manager.getRepository(StudentEntity)
const existing = await repo.find({ select: ['name'] as any })
const existingSet = new Set(existing.map((r: any) => String(r?.name ?? '').trim()))
const toInsert = cleaned.filter((i) => !existingSet.has(i.name))
const now = new Date().toISOString()
if (toInsert.length) {
await repo.insert(
toInsert.map((i) => ({
name: i.name,
score: 0,
extra_json:
typeof i.secrandomId === 'number'
? JSON.stringify({ secrandom_id: i.secrandomId })
: null,
tags: '[]',
created_at: now,
updated_at: now
}))
)
}
return {
inserted: toInsert.length,
skipped: cleaned.length - toInsert.length,
total: cleaned.length
}
})
}
}
-79
View File
@@ -1,79 +0,0 @@
import { DataSource, Repository } from 'typeorm'
import { TagEntity } from '../db/entities/TagEntity'
import { StudentTagEntity } from '../db/entities/StudentTagEntity'
export class TagRepository {
private tagRepo: Repository<TagEntity>
private studentTagRepo: Repository<StudentTagEntity>
constructor(dataSource: DataSource) {
this.tagRepo = dataSource.getRepository(TagEntity)
this.studentTagRepo = dataSource.getRepository(StudentTagEntity)
}
async findAll(): Promise<TagEntity[]> {
return this.tagRepo.find({ order: { created_at: 'ASC' } })
}
async findByName(name: string): Promise<TagEntity | null> {
return this.tagRepo.findOne({ where: { name } })
}
async create(name: string): Promise<TagEntity> {
const tag = this.tagRepo.create({ name })
return this.tagRepo.save(tag)
}
async findOrCreate(name: string): Promise<TagEntity> {
const existing = await this.findByName(name)
if (existing) return existing
return this.create(name)
}
async delete(id: number): Promise<boolean> {
const result = await this.tagRepo.delete(id)
return result.affected === 1
}
async findByStudent(studentId: number): Promise<TagEntity[]> {
const relations = await this.studentTagRepo
.createQueryBuilder('st')
.leftJoinAndSelect('st.tag', 'tag')
.where('st.student_id = :studentId', { studentId })
.orderBy('st.created_at', 'ASC')
.getMany()
return relations.map((r) => r.tag).filter(Boolean)
}
async addTagToStudent(studentId: number, tagId: number): Promise<void> {
const exists = await this.studentTagRepo.findOne({
where: { student_id: studentId, tag_id: tagId }
})
if (!exists) {
const relation = this.studentTagRepo.create({
student_id: studentId,
tag_id: tagId
})
await this.studentTagRepo.save(relation)
}
}
async removeTagFromStudent(studentId: number, tagId: number): Promise<void> {
await this.studentTagRepo.delete({
student_id: studentId,
tag_id: tagId
})
}
async updateStudentTags(studentId: number, tagIds: number[]): Promise<void> {
await this.studentTagRepo.delete({ student_id: studentId })
for (const tagId of tagIds) {
const relation = this.studentTagRepo.create({
student_id: studentId,
tag_id: tagId
})
await this.studentTagRepo.save(relation)
}
}
}
-175
View File
@@ -1,175 +0,0 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { permissionLevel } from './PermissionService'
import crypto from 'crypto'
declare module '../../shared/kernel' {
interface Context {
auth: AuthService
}
}
export class AuthService extends Service {
private SETTINGS_SECURITY_ADMIN = 'security_admin_password'
private SETTINGS_SECURITY_POINTS = 'security_points_password'
private SETTINGS_SECURITY_RECOVERY = 'security_recovery_string'
constructor(ctx: MainContext) {
super(ctx, 'auth')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
const ctx = this.mainCtx
ctx.handle('auth:getStatus', (event) => {
const senderId = event?.sender?.id
const permission =
typeof senderId === 'number'
? ctx.permissions.getPermission(senderId)
: ctx.permissions.getDefaultPermission()
return {
success: true,
data: {
permission,
hasAdminPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN),
hasPointsPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_POINTS),
hasRecoveryString: ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)
}
}
})
ctx.handle('auth:login', async (event, password: string) => {
const senderId = event?.sender?.id
if (typeof senderId !== 'number') return { success: false, message: 'Invalid sender' }
if (!ctx.security.isSixDigit(String(password ?? ''))) {
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
return { success: false, message: 'Invalid password format' }
}
const adminCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_ADMIN)
const pointsCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_POINTS)
const adminPlain = await ctx.security.decryptSecret(adminCipher)
const pointsPlain = await ctx.security.decryptSecret(pointsCipher)
if (adminCipher && adminPlain === password) {
ctx.permissions.setPermission(senderId, 'admin')
return { success: true, data: { permission: 'admin' as permissionLevel } }
}
if (pointsCipher && pointsPlain === password) {
ctx.permissions.setPermission(senderId, 'points')
return { success: true, data: { permission: 'points' as permissionLevel } }
}
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
return { success: false, message: 'Password incorrect' }
})
ctx.handle('auth:logout', (event) => {
const senderId = event?.sender?.id
if (typeof senderId === 'number')
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
return { success: true, data: { permission: ctx.permissions.getDefaultPermission() } }
})
ctx.handle(
'auth:setPasswords',
async (event, payload: { adminPassword?: string | null; pointsPassword?: string | null }) => {
const alreadyHasAdmin = ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN)
if (alreadyHasAdmin && !ctx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const adminPasswordRaw = payload?.adminPassword
const pointsPasswordRaw = payload?.pointsPassword
if (typeof adminPasswordRaw === 'string') {
const trimmed = adminPasswordRaw.trim()
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
else {
if (!ctx.security.isSixDigit(trimmed))
return { success: false, message: 'Admin password must be 6 digits' }
await ctx.settings.setRaw(
this.SETTINGS_SECURITY_ADMIN,
await ctx.security.encryptSecret(trimmed)
)
}
}
if (typeof pointsPasswordRaw === 'string') {
const trimmed = pointsPasswordRaw.trim()
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
else {
if (!ctx.security.isSixDigit(trimmed))
return { success: false, message: 'Points password must be 6 digits' }
await ctx.settings.setRaw(
this.SETTINGS_SECURITY_POINTS,
await ctx.security.encryptSecret(trimmed)
)
}
}
if (!ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)) {
const recovery = crypto.randomBytes(18).toString('base64url')
await ctx.settings.setRaw(
this.SETTINGS_SECURITY_RECOVERY,
await ctx.security.encryptSecret(recovery)
)
return { success: true, data: { recoveryString: recovery } }
}
return { success: true, data: {} }
}
)
ctx.handle('auth:generateRecovery', async (event) => {
if (
ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN) &&
!ctx.permissions.requirePermission(event, 'admin')
)
return { success: false, message: 'Permission denied' }
const recovery = crypto.randomBytes(18).toString('base64url')
await ctx.settings.setRaw(
this.SETTINGS_SECURITY_RECOVERY,
await ctx.security.encryptSecret(recovery)
)
return { success: true, data: { recoveryString: recovery } }
})
ctx.handle('auth:resetByRecovery', async (event, recoveryString: string) => {
const cipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_RECOVERY)
const plain = await ctx.security.decryptSecret(cipher)
if (!plain || plain !== String(recoveryString ?? '').trim())
return { success: false, message: 'Recovery string incorrect' }
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
const newRecovery = crypto.randomBytes(18).toString('base64url')
await ctx.settings.setRaw(
this.SETTINGS_SECURITY_RECOVERY,
await ctx.security.encryptSecret(newRecovery)
)
const senderId = event?.sender?.id
if (typeof senderId === 'number')
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
return { success: true, data: { recoveryString: newRecovery } }
})
ctx.handle('auth:clearAll', async (event) => {
if (!ctx.permissions.requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
await ctx.settings.setRaw(this.SETTINGS_SECURITY_RECOVERY, '')
const senderId = event?.sender?.id
if (typeof senderId === 'number')
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
return { success: true }
})
}
}

Some files were not shown because too many files have changed in this diff Show More