请输入文本

This commit is contained in:
Fox_block
2026-03-01 13:43:52 +08:00
parent 943dc1a27a
commit 38240a42ab
103 changed files with 5382 additions and 1638 deletions
+2 -1
View File
@@ -15,7 +15,6 @@
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。
## 主要功能
- 学生管理
@@ -73,9 +72,11 @@ SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScri
4. 点击“确认提交”
快捷理由:
- 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)
撤销最近记录:
- “最近记录”默认折叠,展开后可对记录点击“撤销”
- 撤销会回滚该条记录对学生积分的影响
-1
View File
@@ -227,4 +227,3 @@ start "" "secscore://leaderboard"
```
此类扩展只需在主进程的 URL 解析函数中增加相应分支逻辑即可。
+101
View File
@@ -0,0 +1,101 @@
# 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
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
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")
}
+21
View File
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,26 @@
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());
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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>
@@ -0,0 +1,5 @@
package com.sectl.secscore;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<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>
@@ -0,0 +1,170 @@
<?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.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?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>
@@ -0,0 +1,5 @@
<?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>
@@ -0,0 +1,5 @@
<?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.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?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>
@@ -0,0 +1,22 @@
<?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>
@@ -0,0 +1,5 @@
<?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>
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,29 @@
// 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
}
+22
View File
@@ -0,0 +1,22 @@
# 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.
+7
View File
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,251 @@
#!/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
@@ -0,0 +1,94 @@
@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
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,26 @@
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
+15 -2
View File
@@ -19,21 +19,33 @@
"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: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",
"antd": "^6.3.1",
"antd-mobile": "^5.42.3",
"better-sqlite3": "^12.6.0",
"chokidar": "^5.0.0",
"es-object-atoms": "^1.1.1",
"express": "^5.2.1",
"mica-electron": "^1.5.16",
"os": "^0.1.2",
"pg": "^8.19.0",
"pinyin-pro": "^3.27.0",
"react-router-dom": "^6.28.0",
"reflect-metadata": "^0.2.2",
"tdesign-react": "^1.16.3",
"typeorm": "^0.3.27",
"uuid": "^13.0.0",
"winston": "^3.19.0",
@@ -46,6 +58,7 @@
"@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",
+1660 -150
View File
File diff suppressed because it is too large Load Diff
+200 -13
View File
@@ -17,18 +17,124 @@ declare module '../../shared/kernel' {
}
}
export class DbManager extends Service {
public readonly dataSource: DataSource
interface PostgreSQLConfig {
host: string
port: number
username: string
password: string
database: string
ssl?: boolean
sslmode?: string
channelBinding?: string
}
constructor(ctx: Context, dbPath: 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')
const dbDir = path.dirname(dbPath)
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 })
}
this.dataSource = new DataSource({
return new DataSource({
type: 'better-sqlite3',
database: dbPath,
database: this.dbPath,
entities: [
StudentEntity,
ReasonEntity,
@@ -43,16 +149,97 @@ export class DbManager extends Service {
logging: false
})
}
}
async initialize() {
if (this.dataSource.isInitialized) return
await this.dataSource.initialize()
await this.dataSource.query('PRAGMA foreign_keys = ON')
await this.dataSource.runMigrations()
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()
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 || '同步失败' }
}
}
}
@@ -3,17 +3,27 @@ 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" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL,
"score" integer NOT NULL DEFAULT (0),
"score" integer NOT NULL ${defaultZero},
"extra_json" text,
"tags" text NOT NULL DEFAULT '[]',
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
"tags" text NOT NULL ${defaultEmptyArr},
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
@@ -21,12 +31,12 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('reasons'))) {
await queryRunner.query(`
CREATE TABLE "reasons" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"id" ${pkSerial} NOT NULL,
"content" text NOT NULL,
"category" text NOT NULL DEFAULT ('其他'),
"category" text NOT NULL DEFAULT '其他',
"delta" integer NOT NULL,
"is_system" integer NOT NULL DEFAULT (0),
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"is_system" integer NOT NULL ${defaultZero},
"updated_at" text NOT NULL DEFAULT ${now},
CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
)
`)
@@ -35,10 +45,10 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('settlements'))) {
await queryRunner.query(`
CREATE TABLE "settlements" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"id" ${pkSerial} NOT NULL,
"start_time" text NOT NULL,
"end_time" text NOT NULL,
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
"created_at" text NOT NULL DEFAULT ${now}
)
`)
}
@@ -46,14 +56,14 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('score_events'))) {
await queryRunner.query(`
CREATE TABLE "score_events" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"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 (CURRENT_TIMESTAMP),
"event_time" text NOT NULL DEFAULT ${now},
"settlement_id" integer,
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
)
@@ -72,23 +82,28 @@ export class InitSchema2026011800000 implements MigrationInterface {
`)
}
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
('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP),
('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP),
('纪律良好','纪律',1,1,CURRENT_TIMESTAMP),
('帮助同学','品德',2,1,CURRENT_TIMESTAMP),
('迟到','纪律',-1,1,CURRENT_TIMESTAMP),
('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP)
${reasonValues}
`)
}
if (!(await queryRunner.hasTable('tags'))) {
await queryRunner.query(`
CREATE TABLE "tags" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL UNIQUE,
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
@@ -96,10 +111,10 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('student_tags'))) {
await queryRunner.query(`
CREATE TABLE "student_tags" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"id" ${pkSerial} NOT NULL,
"student_id" integer NOT NULL,
"tag_id" integer NOT NULL,
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"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")
@@ -114,14 +129,18 @@ export class InitSchema2026011800000 implements MigrationInterface {
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
)
const tagValues = `('优秀生'),('班干部'),('勤奋'),('活跃'),('进步快')`
if (isPg) {
await queryRunner.query(`
INSERT OR IGNORE INTO "tags" ("name") VALUES
('优秀生'),
('班干部'),
('勤奋'),
('活跃'),
('进步快')
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> {
+33 -27
View File
@@ -17,6 +17,7 @@ import { WindowManager, type windowManagerOptions } from './services/WindowManag
import { TrayService } from './services/TrayService'
import { AutoScoreService } from './services/AutoScoreService'
import { FileSystemService } from './services/FileSystemService'
import { DbConnectionService } from './services/DbConnectionService'
import { StudentRepository } from './repos/StudentRepository'
import { ReasonRepository } from './repos/ReasonRepository'
import { EventRepository } from './repos/EventRepository'
@@ -50,6 +51,7 @@ type mainAppConfig = {
logDir: string
themeDir: string
dbPath: string
pgConnectionString?: string
window: windowManagerOptions
}
@@ -191,6 +193,8 @@ app.whenReady().then(async () => {
: ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes'))
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,
@@ -199,6 +203,7 @@ app.whenReady().then(async () => {
configDir,
themeDir,
dbPath,
pgConnectionString,
window: {
icon,
preloadPath: join(__dirname, '../preload/index.js'),
@@ -227,7 +232,10 @@ app.whenReady().then(async () => {
LoggerToken,
(p) => new LoggerService(p.get(MainContext), config.logDir)
)
services.addSingleton(DbManagerToken, (p) => new DbManager(p.get(MainContext), config.dbPath))
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(
@@ -272,14 +280,37 @@ app.whenReady().then(async () => {
(p) => new FileSystemService(p.get(MainContext), config.configDir)
)
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)
@@ -300,32 +331,7 @@ app.whenReady().then(async () => {
services.get(FileSystemServiceToken)
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
await autoScore.initialize?.()
})
.configure(async (_builderContext, appCtx) => {
const services = appCtx.services
services.get(LoggerToken)
const db = services.get(DbManagerToken) as DbManager
await db.initialize()
const settings = services.get(SettingsStoreToken) as SettingsService
await settings.initialize()
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()
}
services.get(FileSystemServiceToken)
services.get(DbConnectionService)
})
const host = await builder.build()
+14 -6
View File
@@ -161,6 +161,10 @@ export class EventRepository extends Service {
})
}
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
@@ -170,8 +174,12 @@ export class EventRepository extends Service {
.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()
}
@@ -193,15 +201,15 @@ export class EventRepository extends Service {
}
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',
'e.student_name = s.name AND e.settlement_id IS NULL AND julianday(e.event_time) >= julianday(:startTime)',
{ startTime }
)
.leftJoin(ScoreEventEntity, 'e', joinCondition, { startTime })
.select('s.id', 'id')
.addSelect('s.name', 'name')
.addSelect('s.score', 'score')
+11 -3
View File
@@ -3,6 +3,10 @@ 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
@@ -64,7 +68,9 @@ export class SettlementRepository extends Service {
}
async findAll(): Promise<settlementSummary[]> {
const qb = this.ctx.db.dataSource
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')
@@ -76,7 +82,7 @@ export class SettlementRepository extends Service {
.from(ScoreEventEntity, 'e')
.where('e.settlement_id = s.id')
}, 'event_count')
.orderBy('julianday(s.end_time)', 'DESC')
.orderBy(orderBy, 'DESC')
const rows = await qb.getRawMany()
return rows.map((r: any) => ({
@@ -100,10 +106,12 @@ export class SettlementRepository extends Service {
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('julianday(s.end_time)', 'DESC')
.orderBy(orderBy, 'DESC')
.limit(1)
.getRawOne<{ end_time?: string }>()
+153
View File
@@ -0,0 +1,153 @@
import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { DataSource } from 'typeorm'
import { StudentEntity } from '../db/entities/StudentEntity'
import { ReasonEntity } from '../db/entities/ReasonEntity'
import { ScoreEventEntity } from '../db/entities/ScoreEventEntity'
import { SettlementEntity } from '../db/entities/SettlementEntity'
import { SettingEntity } from '../db/entities/SettingEntity'
import { TagEntity } from '../db/entities/TagEntity'
import { StudentTagEntity } from '../db/entities/StudentTagEntity'
import { parsePostgreSQLConnectionString } from '../db/DbManager'
declare module '../../shared/kernel' {
interface Context {
dbConnection: DbConnectionService
}
}
export class DbConnectionService extends Service {
private testDataSource: DataSource | null = null
constructor(ctx: MainContext) {
super(ctx, 'dbConnection')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private registerIpc() {
this.mainCtx.handle('db:testConnection', async (_event, connectionString: string) => {
const result = await this.testConnection(connectionString)
return { success: true, data: result }
})
this.mainCtx.handle('db:switchConnection', async (_event, connectionString: string) => {
try {
const result = await this.switchConnection(connectionString)
return { success: true, data: result }
} catch (error: any) {
return { success: false, message: error?.message || '切换失败' }
}
})
this.mainCtx.handle('db:getStatus', async () => {
const status = this.getStatus()
return { success: true, data: status }
})
this.mainCtx.handle('db:sync', async () => {
const db = this.mainCtx.db
const result = await db.syncToRemote()
return { success: result.success, data: result }
})
}
async testConnection(connectionString: string): Promise<{ success: boolean; error?: string }> {
const config = parsePostgreSQLConnectionString(connectionString)
if (!config) {
return { success: false, error: '无效的 PostgreSQL 连接字符串格式' }
}
try {
if (this.testDataSource) {
await this.testDataSource.destroy()
}
this.testDataSource = new DataSource({
type: 'postgres',
host: config.host,
port: config.port,
username: config.username,
password: config.password,
database: config.database,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
extra: {
ssl: config.ssl ? { rejectUnauthorized: false } : undefined,
sslmode: config.sslmode,
channelBinding: config.channelBinding
},
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
synchronize: false,
logging: false,
connectTimeoutMS: 10000
})
await this.testDataSource.initialize()
await this.testDataSource.destroy()
this.testDataSource = null
return { success: true }
} catch (error: any) {
if (this.testDataSource) {
try {
await this.testDataSource.destroy()
} catch {}
this.testDataSource = null
}
console.error('PostgreSQL connection test failed:', error)
return { success: false, error: error?.message || '连接失败' }
}
}
async switchConnection(connectionString: string): Promise<{ type: 'sqlite' | 'postgresql' }> {
if (!connectionString) {
// 切换到 SQLite
const result = await this.mainCtx.db.switchConnection(undefined)
await this.mainCtx.settings.setValue('pg_connection_string', '')
await this.mainCtx.settings.setValue('pg_connection_status', {
connected: true,
type: 'sqlite'
})
return result
}
const testResult = await this.testConnection(connectionString)
if (!testResult.success) {
await this.mainCtx.settings.setValue('pg_connection_status', {
connected: false,
type: 'postgresql',
error: testResult.error
})
throw new Error(testResult.error || '连接测试失败')
}
// 切换到 PostgreSQL
const result = await this.mainCtx.db.switchConnection(connectionString)
await this.mainCtx.settings.setValue('pg_connection_string', connectionString)
await this.mainCtx.settings.setValue('pg_connection_status', {
connected: true,
type: 'postgresql'
})
return result
}
getStatus(): { type: 'sqlite' | 'postgresql'; connected: boolean; error?: string } {
const status = this.mainCtx.settings.getValue('pg_connection_status')
if (status) {
return status
}
return { type: 'sqlite', connected: true }
}
}
+10
View File
@@ -93,6 +93,16 @@ export class SettingsService extends Service {
kind: 'string',
defaultValue: 'light-default',
writePermission: 'admin'
},
pg_connection_string: {
kind: 'string',
defaultValue: '',
writePermission: 'admin'
},
pg_connection_status: {
kind: 'json',
defaultValue: { connected: false, type: 'sqlite' },
writePermission: 'admin'
}
}
+12
View File
@@ -29,6 +29,13 @@ export class TrayService extends Service {
}
},
{ type: 'separator' },
{
label: '重启应用',
click: () => {
this.restartApp()
}
},
{ type: 'separator' },
{
label: '关闭应用',
click: () => {
@@ -55,4 +62,9 @@ export class TrayService extends Service {
this.mainCtx.windows.open({ key: 'main', title: 'SecScore', route: '/' })
}
}
private restartApp() {
app.relaunch()
app.quit()
}
}
+131
View File
@@ -0,0 +1,131 @@
import { ConfigProvider, theme as antTheme, Layout, message } from 'antd'
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, createContext, useContext } from 'react'
import { MobileHome } from './pages/Home'
import { MobileScore } from './pages/Score'
import { MobileLeaderboard } from './pages/Leaderboard'
import { MobileSettings } from './pages/Settings'
import { MobileLayout } from './components/Layout'
interface ThemeContextType {
isDark: boolean
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({
isDark: false,
toggleTheme: () => {}
})
export const useTheme = () => useContext(ThemeContext)
interface ApiConfig {
baseUrl: string
}
interface ApiContextType {
api: {
get: (path: string) => Promise<any>
post: (path: string, data?: any) => Promise<any>
put: (path: string, data?: any) => Promise<any>
del: (path: string) => Promise<any>
}
config: ApiConfig
setConfig: (config: ApiConfig) => void
}
const ApiContext = createContext<ApiContextType | null>(null)
export const useApi = () => {
const ctx = useContext(ApiContext)
if (!ctx) throw new Error('useApi must be used within ApiProvider')
return ctx
}
function App(): React.JSX.Element {
const [isDark, setIsDark] = useState(false)
const [apiConfig, setApiConfig] = useState<ApiConfig>({ baseUrl: '' })
const [messageApi, messageHolder] = message.useMessage()
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark') setIsDark(true)
const savedApiUrl = localStorage.getItem('apiUrl')
if (savedApiUrl) setApiConfig({ baseUrl: savedApiUrl })
}, [])
useEffect(() => {
document.documentElement.setAttribute('theme-mode', isDark ? 'dark' : 'light')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}, [isDark])
useEffect(() => {
if (apiConfig.baseUrl) {
localStorage.setItem('apiUrl', apiConfig.baseUrl)
}
}, [apiConfig])
const toggleTheme = () => setIsDark((prev) => !prev)
const api = {
get: async (path: string) => {
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
const res = await fetch(`${apiConfig.baseUrl}${path}`)
return res.json()
},
post: async (path: string, data?: any) => {
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
const res = await fetch(`${apiConfig.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
return res.json()
},
put: async (path: string, data?: any) => {
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
const res = await fetch(`${apiConfig.baseUrl}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
return res.json()
},
del: async (path: string) => {
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
const res = await fetch(`${apiConfig.baseUrl}${path}`, { method: 'DELETE' })
return res.json()
}
}
return (
<ConfigProvider
theme={{
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
token: {
colorPrimary: '#1890ff'
}
}}
>
{messageHolder}
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
<ApiContext.Provider value={{ api, config: apiConfig, setConfig: setApiConfig }}>
<HashRouter>
<Routes>
<Route element={<MobileLayout />}>
<Route path="/" element={<MobileHome />} />
<Route path="/score" element={<MobileScore />} />
<Route path="/leaderboard" element={<MobileLeaderboard />} />
<Route path="/settings" element={<MobileSettings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>
</ApiContext.Provider>
</ThemeContext.Provider>
</ConfigProvider>
)
}
export default App
+63
View File
@@ -0,0 +1,63 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Layout, TabBar } from 'antd-mobile'
import {
HomeOutlined,
PlusCircleOutlined,
TrophyOutlined,
SettingOutlined
} from '@ant-design/icons'
import { useTheme } from '../App'
import { Switch } from 'antd'
const { Content } = Layout
const tabs = [
{ key: '/', title: '主页', icon: <HomeOutlined /> },
{ key: '/score', title: '积分', icon: <PlusCircleOutlined /> },
{ key: '/leaderboard', title: '排行', icon: <TrophyOutlined /> },
{ key: '/settings', title: '设置', icon: <SettingOutlined /> }
]
export function MobileLayout(): React.JSX.Element {
const location = useLocation()
const navigate = useNavigate()
const { isDark, toggleTheme } = useTheme()
return (
<Layout
style={{
minHeight: '100vh',
backgroundColor: isDark ? '#1a1a1a' : '#f5f5f5'
}}
>
<Content style={{ paddingBottom: '50px' }}>
<Outlet />
</Content>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
backgroundColor: isDark ? '#2a2a2a' : '#fff',
borderTop: `1px solid ${isDark ? '#333' : '#eee'}`
}}
>
<TabBar
activeKey={location.pathname}
onChange={(key) => navigate(key)}
items={tabs.map((tab) => ({
key: tab.key,
title: tab.title,
icon: tab.icon
}))}
style={{
'--color': isDark ? '#999' : '#666',
'--active-color': '#1890ff',
backgroundColor: 'transparent'
}}
/>
</div>
</Layout>
)
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="theme-color" content="#1890ff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>SecScore - 教育积分管理</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import '../renderer/src/assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+122
View File
@@ -0,0 +1,122 @@
import { useState, useEffect } from 'react'
import { Card, Tag, SearchBar, Space, SpinLoading } from 'antd-mobile'
import { useApi } from '../App'
interface Student {
id: number
name: string
score: number
}
export function MobileHome(): React.JSX.Element {
const { api, config } = useApi()
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
useEffect(() => {
if (!config.baseUrl) return
loadStudents()
}, [config.baseUrl])
const loadStudents = async () => {
setLoading(true)
try {
const res = await api.get('/api/students')
if (res.success) {
setStudents(res.data || [])
}
} catch (e) {
console.error('Failed to load students:', e)
} finally {
setLoading(false)
}
}
const filteredStudents = students.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase())
)
const getAvatarColor = (name: string) => {
const colors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#FFA07A',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E2'
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
if (!config.baseUrl) {
return (
<div style={{ padding: '16px', textAlign: 'center' }}>
<p></p>
</div>
)
}
return (
<div style={{ padding: '12px' }}>
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}></h2>
<SearchBar
placeholder="搜索学生"
value={search}
onChange={setSearch}
style={{ marginBottom: '12px' }}
/>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<SpinLoading />
</div>
) : (
<Space direction="vertical" style={{ width: '100%' }}>
{filteredStudents.map((student) => (
<Card key={student.id} style={{ borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: getAvatarColor(student.name),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold'
}}
>
{student.name.slice(-2)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{student.name}</div>
<Tag
color={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
>
{student.score > 0 ? `+${student.score}` : student.score}
</Tag>
</div>
</div>
</Card>
))}
</Space>
)}
{!loading && filteredStudents.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{search ? '未找到匹配的学生' : '暂无学生数据'}
</div>
)}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { Card, SpinLoading, Selector } from 'antd-mobile'
import { useApi } from '../App'
interface RankRow {
name: string
score: number
range_change: number
}
export function MobileLeaderboard(): React.JSX.Element {
const { api, config } = useApi()
const [data, setData] = useState<RankRow[]>([])
const [loading, setLoading] = useState(false)
const [range, setRange] = useState<'today' | 'week' | 'month'>('today')
useEffect(() => {
if (!config.baseUrl) return
loadData()
}, [config.baseUrl, range])
const loadData = async () => {
setLoading(true)
try {
const res = await api.get(`/api/leaderboard?range=${range}`)
if (res.success) {
setData(res.data?.rows || [])
}
} catch (e) {
console.error('Failed to load leaderboard:', e)
} finally {
setLoading(false)
}
}
if (!config.baseUrl) {
return (
<div style={{ padding: '16px', textAlign: 'center' }}>
<p></p>
</div>
)
}
return (
<div style={{ padding: '12px' }}>
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}></h2>
<Selector
columns={3}
value={[range]}
onChange={(v) => setRange(v[0] as 'today' | 'week' | 'month')}
options={[
{ label: '今天', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]}
style={{ marginBottom: '12px' }}
/>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<SpinLoading />
</div>
) : (
<Card>
{data.map((row, index) => (
<div
key={row.name}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 0',
borderBottom: index < data.length - 1 ? '1px solid var(--adm-color-border)' : 'none'
}}
>
<div
style={{
width: '32px',
fontWeight: 'bold',
color: index < 3 ? '#1890ff' : 'inherit'
}}
>
{index + 1}
</div>
<div style={{ flex: 1, fontWeight: 500 }}>{row.name}</div>
<div style={{ fontWeight: 'bold', marginRight: '12px' }}>{row.score}</div>
<div
style={{
color:
row.range_change > 0 ? '#52c41a' : row.range_change < 0 ? '#ff4d4f' : '#999'
}}
>
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
</div>
</div>
))}
{data.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}></div>
)}
</Card>
)}
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useState, useEffect } from 'react'
import { Card, Button, Selector, Input, TextArea, SpinLoading, Toast } from 'antd-mobile'
import { useApi } from '../App'
interface Student {
id: number
name: string
score: number
}
interface Reason {
id: number
content: string
delta: number
category: string
}
export function MobileScore(): React.JSX.Element {
const { api, config } = useApi()
const [students, setStudents] = useState<Student[]>([])
const [reasons, setReasons] = useState<Reason[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [selectedStudents, setSelectedStudents] = useState<string[]>([])
const [selectedReason, setSelectedReason] = useState<Reason | null>(null)
const [customScore, setCustomScore] = useState('')
const [customReason, setCustomReason] = useState('')
useEffect(() => {
if (!config.baseUrl) return
loadData()
}, [config.baseUrl])
const loadData = async () => {
setLoading(true)
try {
const [stuRes, reaRes] = await Promise.all([
api.get('/api/students'),
api.get('/api/reasons')
])
if (stuRes.success) setStudents(stuRes.data || [])
if (reaRes.success) setReasons(reaRes.data || [])
} catch (e) {
console.error('Failed to load data:', e)
} finally {
setLoading(false)
}
}
const handleSubmit = async () => {
if (selectedStudents.length === 0) {
Toast.show({ content: '请选择学生', icon: 'fail' })
return
}
const delta = selectedReason?.delta ?? parseInt(customScore, 10)
if (isNaN(delta)) {
Toast.show({ content: '请选择理由或输入分值', icon: 'fail' })
return
}
const reasonContent = selectedReason?.content || customReason || '积分变更'
setSubmitting(true)
try {
let successCount = 0
for (const studentName of selectedStudents) {
const res = await api.post('/api/events', {
student_name: studentName,
reason_content: reasonContent,
delta
})
if (res.success) successCount++
}
Toast.show({
content: `已为 ${successCount} 名学生提交积分`,
icon: 'success'
})
setSelectedStudents([])
setSelectedReason(null)
setCustomScore('')
setCustomReason('')
} catch (e) {
Toast.show({ content: '提交失败', icon: 'fail' })
} finally {
setSubmitting(false)
}
}
if (!config.baseUrl) {
return (
<div style={{ padding: '16px', textAlign: 'center' }}>
<p></p>
</div>
)
}
return (
<div style={{ padding: '12px' }}>
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}></h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<SpinLoading />
</div>
) : (
<>
<Card title="选择学生" style={{ marginBottom: '12px' }}>
<Selector
columns={3}
multiple
value={selectedStudents}
onChange={(v) => setSelectedStudents(v as string[])}
options={students.map((s) => ({
label: s.name,
value: s.name
}))}
/>
</Card>
<Card title="快捷理由" style={{ marginBottom: '12px' }}>
<Selector
columns={2}
value={selectedReason ? [selectedReason.id] : []}
onChange={(v) => {
const reason = reasons.find((r) => r.id === v[0])
setSelectedReason(reason || null)
if (reason) {
setCustomScore(String(Math.abs(reason.delta)))
}
}}
options={reasons.map((r) => ({
label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
value: r.id
}))}
/>
</Card>
<Card title="自定义" style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Input
placeholder="分值(如:2 或 -2"
value={customScore}
onChange={setCustomScore}
type="number"
/>
<TextArea
placeholder="理由(可选)"
value={customReason}
onChange={setCustomReason}
rows={2}
/>
</div>
</Card>
<Button block color="primary" size="large" loading={submitting} onClick={handleSubmit}>
</Button>
</>
)}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { Card, Input, Button, List, Switch, Toast } from 'antd-mobile'
import { useTheme, useApi } from '../App'
export function MobileSettings(): React.JSX.Element {
const { isDark, toggleTheme } = useTheme()
const { config, setConfig } = useApi()
const [apiUrl, setApiUrl] = useState(config.baseUrl)
const [testing, setTesting] = useState(false)
const handleSaveApiUrl = () => {
setConfig({ baseUrl: apiUrl })
Toast.show({ content: '已保存服务器地址', icon: 'success' })
}
const handleTestConnection = async () => {
if (!apiUrl) {
Toast.show({ content: '请输入服务器地址', icon: 'fail' })
return
}
setTesting(true)
try {
const res = await fetch(`${apiUrl}/api/students`)
if (res.ok) {
Toast.show({ content: '连接成功', icon: 'success' })
} else {
Toast.show({ content: '连接失败', icon: 'fail' })
}
} catch {
Toast.show({ content: '连接失败', icon: 'fail' })
} finally {
setTesting(false)
}
}
return (
<div style={{ padding: '12px' }}>
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}></h2>
<Card style={{ marginBottom: '12px' }}>
<List header="外观">
<List.Item extra={<Switch checked={isDark} onChange={toggleTheme} />}></List.Item>
</List>
</Card>
<Card style={{ marginBottom: '12px' }}>
<List header="服务器配置">
<div style={{ padding: '12px' }}>
<div style={{ marginBottom: '8px', color: '#666', fontSize: '12px' }}>
SecScore HTTP
</div>
<Input
placeholder="http://192.168.1.100:3000"
value={apiUrl}
onChange={setApiUrl}
style={{ marginBottom: '12px' }}
/>
<div style={{ display: 'flex', gap: '8px' }}>
<Button color="primary" size="small" onClick={handleSaveApiUrl}>
</Button>
<Button size="small" loading={testing} onClick={handleTestConnection}>
</Button>
</div>
</div>
</List>
</Card>
<Card>
<List header="关于">
<List.Item>SecScore </List.Item>
<List.Item description="连接桌面端进行积分操作"> 1.0.0</List.Item>
</List>
</Card>
</div>
)
}
+8
View File
@@ -113,6 +113,14 @@ const api = {
registerUrlProtocol: () => ipcRenderer.invoke('app:register-url-protocol'),
// Database Connection
dbTestConnection: (connectionString: string) =>
ipcRenderer.invoke('db:testConnection', connectionString),
dbSwitchConnection: (connectionString: string) =>
ipcRenderer.invoke('db:switchConnection', connectionString),
dbGetStatus: () => ipcRenderer.invoke('db:getStatus'),
dbSync: () => ipcRenderer.invoke('db:sync'),
// HTTP Server
httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) =>
ipcRenderer.invoke('http:server:start', config),
+14
View File
@@ -32,6 +32,8 @@ export type settingsSpec = {
auto_score_enabled: boolean
auto_score_rules: any[]
current_theme_id: string
pg_connection_string: string
pg_connection_status: { connected: boolean; type: 'sqlite' | 'postgresql'; error?: string }
}
export type settingsKey = keyof settingsSpec
@@ -176,6 +178,18 @@ export interface electronApi {
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>>
// Database Connection
dbTestConnection: (
connectionString: string
) => Promise<ipcResponse<{ success: boolean; error?: string }>>
dbSwitchConnection: (
connectionString: string
) => Promise<ipcResponse<{ type: 'sqlite' | 'postgresql' }>>
dbGetStatus: () => Promise<
ipcResponse<{ type: 'sqlite' | 'postgresql'; connected: boolean; error?: string }>
>
dbSync: () => Promise<ipcResponse<{ success: boolean; message?: string }>>
// HTTP Server
httpServerStart: (config?: {
port?: number
+30 -20
View File
@@ -1,23 +1,22 @@
import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react'
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
import { Sidebar } from './components/Sidebar'
import { ContentArea } from './components/ContentArea'
import { Wizard } from './components/Wizard'
import { ThemeProvider } from './contexts/ThemeContext'
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
import { ThemeEditorProvider } from './contexts/ThemeEditorContext'
import { ThemeEditor } from './components/ThemeEditor'
import { useTheme } from './contexts/ThemeContext'
function MainContent(): React.JSX.Element {
const navigate = useNavigate()
const location = useLocation()
const { currentTheme } = useTheme()
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => {
if (!(window as any).api) return
const unlisten = (window as any).api.onNavigate((route: string) => {
// 统一路径格式进行比对,防止 / 和 /home 导致重复跳转
const currentPath = location.pathname === '/' ? '/home' : location.pathname
const targetPath = route === '/' ? '/home' : route
@@ -83,9 +82,9 @@ function MainContent(): React.JSX.Element {
setPermission(res.data.permission)
setAuthVisible(false)
setAuthPassword('')
MessagePlugin.success('权限已解锁')
messageApi.success('权限已解锁')
} else {
MessagePlugin.error(res.message || '密码错误')
messageApi.error(res.message || '密码错误')
}
}
@@ -94,11 +93,11 @@ function MainContent(): React.JSX.Element {
const res = await (window as any).api.authLogout()
if (res?.success && res.data) {
setPermission(res.data.permission)
MessagePlugin.success('已切换为只读')
messageApi.success('已切换为只读')
}
}
const onMenuChange = (v: string | number) => {
const onMenuChange = (v: string) => {
const key = String(v)
if (key === 'home') navigate('/')
if (key === 'students') navigate('/students')
@@ -110,7 +109,18 @@ function MainContent(): React.JSX.Element {
if (key === 'settings') navigate('/settings')
}
const isDark = currentTheme?.mode === 'dark'
return (
<ConfigProvider
theme={{
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
token: {
colorPrimary: 'var(--ant-color-primary, #1890ff)'
}
}}
>
{contextHolder}
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
<ContentArea
@@ -122,12 +132,14 @@ function MainContent(): React.JSX.Element {
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Dialog
header="权限解锁"
visible={authVisible}
onClose={() => setAuthVisible(false)}
onConfirm={login}
confirmBtn={{ content: '解锁', loading: authLoading }}
<Modal
title="权限解锁"
open={authVisible}
onCancel={() => setAuthVisible(false)}
onOk={login}
confirmLoading={authLoading}
okText="解锁"
cancelText="取消"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
@@ -135,12 +147,12 @@ function MainContent(): React.JSX.Element {
</div>
<Input
value={authPassword}
onChange={(v) => setAuthPassword(v)}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="例如 123456"
maxlength={6}
maxLength={6}
/>
</div>
</Dialog>
</Modal>
{import.meta.env.DEV ? (
<div
@@ -176,11 +188,11 @@ function MainContent(): React.JSX.Element {
</div>
) : null}
</Layout>
</ConfigProvider>
)
}
function getArchitecture(): string {
// 尝试从 userAgent 中获取架构信息
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('arm64') || userAgent.includes('aarch64')) {
@@ -191,12 +203,10 @@ function getArchitecture(): string {
return 'x86'
}
// 默认返回未知架构
return userAgent
}
function getPlatform(): string {
// 尝试从 userAgent 中获取平台信息
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('windows')) {
+21 -62
View File
@@ -1,14 +1,4 @@
@import 'tdesign-react/dist/reset.css';
@import 'tdesign-react/dist/tdesign.css';
/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */
.t-layout {
background-color: var(--ss-bg-color) !important;
}
.t-default-menu{
background-color: transparent !important;
}
/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */
@import 'antd/dist/reset.css';
html,
body,
@@ -17,11 +7,11 @@ body,
margin: 0;
padding: 0;
overflow: hidden;
user-select: none; /* 禁用文本选择 */
user-select: none;
}
/* 如果某些输入框需要允许选择,可以单独开启 */
input, textarea {
input,
textarea {
user-select: text;
}
@@ -29,79 +19,48 @@ input, textarea {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item,
.ss-sidebar .t-menu__item-icon {
.ss-sidebar .ant-menu {
background-color: transparent !important;
}
.ss-sidebar .ant-menu-item {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item--active,
.ss-sidebar .t-menu__item.t-is-active,
.ss-sidebar .t-menu__item--active .t-menu__item-icon,
.ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
.ss-sidebar .t-menu__item--active .t-menu__content,
.ss-sidebar .t-menu__item.t-is-active .t-menu__content {
.ss-sidebar .ant-menu-item-selected {
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main)));
}
.ss-sidebar .t-menu__item--active,
.ss-sidebar .t-menu__item.t-is-active {
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active,
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__item-icon,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__content,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__content {
html[theme-mode='dark'] .ss-sidebar .ant-menu-item-selected {
color: #ffffff !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg[fill='none'],
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg[fill='none'] {
stroke: currentColor !important;
fill: none !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg:not([fill='none']),
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='none']) {
fill: currentColor !important;
}
.ss-sidebar .t-menu__item:hover {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
@media (pointer: coarse) {
.ss-sidebar .t-menu__item {
.ss-sidebar .ant-menu-item {
min-height: 48px;
font-size: 16px;
}
.t-button {
.ant-btn {
min-height: 44px;
}
.t-input,
.t-input__inner,
.t-select-input,
.t-input-number,
.t-input-number__input {
.ant-input,
.ant-select-selector,
.ant-input-number {
min-height: 44px;
font-size: 16px;
}
.t-table th,
.t-table td {
.ant-table th,
.ant-table td {
padding-top: 14px;
padding-bottom: 14px;
}
}
.ss-table-center .t-table th,
.ss-table-center .t-table td,
.ss-table-center .t-table th .t-table__cell,
.ss-table-center .t-table td .t-table__cell {
.ss-table-center .ant-table th,
.ss-table-center .ant-table td {
text-align: center;
justify-content: center;
}
+103 -111
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { AddIcon, MoveIcon } from 'tdesign-icons-react'
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
import type { TriggerItem, ActionItem } from './com.automatically/types'
import TriggerItemComponent from './com.automatically/TriggerItem'
@@ -9,15 +9,16 @@ import {
Form,
Input,
Button,
MessagePlugin,
message,
Table,
PrimaryTableCol,
Space,
Switch,
Popconfirm,
Select,
TooltipLite
} from 'tdesign-react'
Tooltip,
Pagination
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface AutoScoreRule {
id: number
@@ -44,6 +45,7 @@ export const AutoScoreManager: React.FC = () => {
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [triggerList, setTriggerList] = useState<TriggerItem[]>([])
const [actionList, setActionList] = useState<ActionItem[]>([])
const [messageApi, contextHolder] = message.useMessage()
const fetchRules = async () => {
if (!(window as any).api) return
@@ -53,7 +55,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
setLoading(false)
return
}
@@ -68,14 +70,14 @@ export const AutoScoreManager: React.FC = () => {
if (rulesRes.success) {
setRules(rulesRes.data)
} else {
MessagePlugin.error(rulesRes.message || '获取自动化失败')
messageApi.error(rulesRes.message || '获取自动化失败')
}
if (studentsRes.success) {
setStudents(studentsRes.data)
}
} catch (error) {
console.error('Failed to fetch auto score rules:', error)
MessagePlugin.error('获取自动化失败')
messageApi.error('获取自动化失败')
} finally {
setLoading(false)
}
@@ -91,17 +93,17 @@ export const AutoScoreManager: React.FC = () => {
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
if (!values.name) {
MessagePlugin.warning('请填写自动化名称')
messageApi.warning('请填写自动化名称')
return
}
if (triggerList.length === 0) {
MessagePlugin.warning('请至少添加一个触发器')
messageApi.warning('请至少添加一个触发器')
return
}
if (actionList.length === 0) {
MessagePlugin.warning('请至少添加一个行动')
messageApi.warning('请至少添加一个行动')
return
}
@@ -129,7 +131,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以创建或更新自动加分自动化')
messageApi.error('需要管理员权限以创建或更新自动加分自动化')
return
}
} catch (e) {
@@ -148,7 +150,7 @@ export const AutoScoreManager: React.FC = () => {
}
if (res.success) {
MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
form.setFieldsValue({
name: '',
studentNames: ''
@@ -158,13 +160,13 @@ export const AutoScoreManager: React.FC = () => {
setActionList([])
fetchRules()
} else {
MessagePlugin.error(
messageApi.error(
res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
)
}
} catch (error) {
console.error('Failed to submit auto score rule:', error)
MessagePlugin.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
messageApi.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
}
}
@@ -203,7 +205,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以删除自动加分自动化')
messageApi.error('需要管理员权限以删除自动加分自动化')
return
}
} catch (e) {
@@ -213,14 +215,14 @@ export const AutoScoreManager: React.FC = () => {
try {
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
if (res.success) {
MessagePlugin.success('自动化删除成功')
messageApi.success('自动化删除成功')
fetchRules()
} else {
MessagePlugin.error(res.message || '删除自动化失败')
messageApi.error(res.message || '删除自动化失败')
}
} catch (error) {
console.error('Failed to delete auto score rule:', error)
MessagePlugin.error('删除自动化失败')
messageApi.error('删除自动化失败')
}
}
@@ -229,7 +231,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以启用/禁用自动加分自动化')
messageApi.error('需要管理员权限以启用/禁用自动加分自动化')
return
}
} catch (e) {
@@ -239,14 +241,14 @@ export const AutoScoreManager: React.FC = () => {
try {
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
if (res.success) {
MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用')
messageApi.success(enabled ? '自动化已启用' : '自动化已禁用')
fetchRules()
} else {
MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
}
} catch (error) {
console.error('Failed to toggle auto score rule:', error)
MessagePlugin.error(enabled ? '启用自动化失败' : '禁用自动化失败')
messageApi.error(enabled ? '启用自动化失败' : '禁用自动化失败')
}
}
@@ -264,7 +266,7 @@ export const AutoScoreManager: React.FC = () => {
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
const defaultTrigger = allTriggers.list[0]
if (!defaultTrigger) {
MessagePlugin.error('没有可用的触发器类型,请检查配置')
messageApi.error('没有可用的触发器类型,请检查配置')
return
}
setTriggerList((prev) => [
@@ -298,7 +300,7 @@ export const AutoScoreManager: React.FC = () => {
const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
const defaultAction = allActions.list[0]
if (!defaultAction) {
MessagePlugin.error('没有可用的行动类型,请检查配置')
messageApi.error('没有可用的行动类型,请检查配置')
return
}
setActionList((prev) => [
@@ -328,98 +330,89 @@ export const AutoScoreManager: React.FC = () => {
setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a)))
}
const columns: PrimaryTableCol<AutoScoreRule>[] = [
const columns: ColumnsType<AutoScoreRule> = [
{
colKey: 'drag',
key: 'drag',
title: '排序',
cell: () => <MoveIcon />,
width: 60
width: 60,
render: () => <HolderOutlined style={{ cursor: 'move' }} />
},
{
colKey: 'enabled',
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
cell: ({ row }) => (
<Switch
value={row.enabled}
onChange={(value) => handleToggle(row.id, value)}
size="small"
/>
render: (enabled: boolean, row) => (
<Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
)
},
{ colKey: 'name', title: '自动化名称', width: 150 },
{ title: '自动化名称', dataIndex: 'name', key: 'name', width: 150 },
{
colKey: 'triggers',
title: '触发器',
dataIndex: 'triggers',
key: 'triggers',
width: 150,
cell: ({ row }) => {
if (!row.triggers || row.triggers.length === 0) {
render: (triggers: AutoScoreRule['triggers']) => {
if (!triggers || triggers.length === 0) {
return <span></span>
}
const triggerLabels = row.triggers.map((t) => {
const triggerLabels = triggers.map((t) => {
const def = triggerRegistry.get(t.event)
return def?.label || t.event
})
return (
<TooltipLite
content={triggerLabels.join(', ')}
showArrow
placement="mouse"
theme="default"
>
{row.triggers.length}
</TooltipLite>
<Tooltip title={triggerLabels.join(', ')}>
<span>{triggers.length} </span>
</Tooltip>
)
}
},
{
colKey: 'actions',
title: '行动',
dataIndex: 'actions',
key: 'actions',
width: 150,
cell: ({ row }) => {
if (!row.actions || row.actions.length === 0) {
render: (actions: AutoScoreRule['actions']) => {
if (!actions || actions.length === 0) {
return <span></span>
}
const actionLabels = row.actions.map((a) => {
const actionLabels = actions.map((a) => {
const def = actionRegistry.get(a.event)
return def?.label || a.event
})
return (
<TooltipLite
content={actionLabels.join(', ')}
showArrow
placement="mouse"
theme="default"
>
{row.actions.length}
</TooltipLite>
<Tooltip title={actionLabels.join(', ')}>
<span>{actions.length} </span>
</Tooltip>
)
}
},
{
colKey: 'studentNames',
title: '适用学生',
dataIndex: 'studentNames',
key: 'studentNames',
width: 130,
cell: ({ row }) => {
if (!row.studentNames || row.studentNames.length === 0) {
render: (studentNames: string[]) => {
if (!studentNames || studentNames.length === 0) {
return <span></span>
}
const studentList = row.studentNames.join(',\n')
const studentList = studentNames.join(',\n')
return (
<TooltipLite content={studentList} showArrow placement="mouse" theme="default">
{row.studentNames.length}
</TooltipLite>
<Tooltip title={studentList}>
<span>{studentNames.length} </span>
</Tooltip>
)
}
},
{
colKey: 'lastExecuted',
title: '最后执行',
dataIndex: 'lastExecuted',
key: 'lastExecuted',
width: 180,
cell: ({ row }) => {
if (!row.lastExecuted) return <span></span>
render: (lastExecuted: string) => {
if (!lastExecuted) return <span></span>
try {
const date = new Date(row.lastExecuted)
const date = new Date(lastExecuted)
return date.toLocaleString()
} catch {
return <span></span>
@@ -427,16 +420,16 @@ export const AutoScoreManager: React.FC = () => {
}
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
render: (_, row) => (
<Space>
<Button size="small" variant="outline" onClick={() => handleEdit(row)}>
<Button size="small" onClick={() => handleEdit(row)}>
</Button>
<Popconfirm content="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
<Button size="small" variant="outline" theme="danger">
<Popconfirm title="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
<Button size="small" danger>
</Button>
</Popconfirm>
@@ -445,8 +438,6 @@ export const AutoScoreManager: React.FC = () => {
}
]
const onDragSort = (params: any) => setRules(params.newData)
const triggerItems = triggerList
.filter((t) => t.eventName !== null)
.map((item, idx) => (
@@ -476,34 +467,35 @@ export const AutoScoreManager: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form form={form} labelWidth={100} onReset={handleResetForm}>
<Form form={form} layout="vertical">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem
<Form.Item
label="自动化名称"
name="name"
rules={[{ required: true, message: '请输入自动化名称' }]}
>
<Input placeholder="例如:每日签到加分" />
</Form.FormItem>
</Form.Item>
<Form.FormItem label="适用学生" name="studentNames">
<Form.Item label="适用学生" name="studentNames">
<Select
filterable
multiple
mode="multiple"
showSearch
placeholder="请选择或搜索学生(留空表示所有学生)"
options={students.map((student) => ({ label: student.name, value: student.name }))}
/>
</Form.FormItem>
</Form.Item>
</div>
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
<Button theme="primary" onClick={handleSubmit}>
<Button type="primary" onClick={handleSubmit}>
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
</Button>
<Button type="reset" variant="outline">
<Button onClick={handleResetForm}>
{editingRuleId !== null ? '取消编辑' : '重置表单'}
</Button>
</div>
@@ -513,16 +505,14 @@ export const AutoScoreManager: React.FC = () => {
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="当以下规则触发时"
headerBordered
>
<Space style={{ display: 'grid' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{triggerItems}
<Button
theme="default"
variant="text"
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddTrigger}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
@@ -532,16 +522,14 @@ export const AutoScoreManager: React.FC = () => {
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="满足规则时触发的行动"
headerBordered
>
<Space style={{ display: 'grid' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{actionItems}
<Button
theme="default"
variant="text"
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddAction}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
@@ -550,22 +538,26 @@ export const AutoScoreManager: React.FC = () => {
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
dataSource={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
columns={columns}
rowKey="id"
resizable
loading={loading}
dragSort="row-handler"
onDragSort={onDragSort}
pagination={{
current: currentPage,
pageSize,
total: rules.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
pagination={false}
style={{ color: 'var(--ss-text-main)' }}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={rules.length}
onChange={(page, size) => {
setCurrentPage(page)
setPageSize(size)
}}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div>
</Card>
</div>
)
+5 -6
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy } from 'react'
import { Layout, Space, Button, Tag, Loading } from 'tdesign-react'
import { Layout, Space, Button, Tag, Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { WindowControls } from './WindowControls'
import { ThemeEditor } from './ThemeEditor'
@@ -38,8 +38,7 @@ export function ContentArea({
}: ContentAreaProps): React.JSX.Element {
const permissionTag = (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
@@ -83,10 +82,10 @@ export function ContentArea({
{permissionTag}
{hasAnyPassword && (
<>
<Button size="small" variant="outline" onClick={onAuthClick}>
<Button size="small" onClick={onAuthClick}>
</Button>
<Button size="small" variant="outline" theme="danger" onClick={onLogout}>
<Button size="small" danger onClick={onLogout}>
</Button>
</>
@@ -107,7 +106,7 @@ export function ContentArea({
height: '100%'
}}
>
<Loading text="正在载入页面..." />
<Spin size="large" description="正在载入页面..." />
</div>
}
>
+15 -16
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Table, PrimaryTableCol, Tag } from 'tdesign-react'
import { Table, Tag } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface scoreEvent {
id: number
@@ -30,26 +31,26 @@ export const EventHistory: React.FC = () => {
fetchEvents()
}, [])
const columns: PrimaryTableCol<scoreEvent>[] = [
{ colKey: 'student_name', title: '学生姓名', width: 120 },
{ colKey: 'reason_content', title: '积分理由', width: 200 },
const columns: ColumnsType<scoreEvent> = [
{ title: '学生姓名', dataIndex: 'student_name', key: 'student_name', width: 120 },
{ title: '积分理由', dataIndex: 'reason_content', key: 'reason_content', width: 200 },
{
colKey: 'delta',
title: '分值变动',
dataIndex: 'delta',
key: 'delta',
width: 100,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
render: (delta: number) => (
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
)
},
{ colKey: 'val_prev', title: '原分值', width: 100 },
{ colKey: 'val_curr', title: '新分值', width: 100 },
{ title: '原分值', dataIndex: 'val_prev', key: 'val_prev', width: 100 },
{ title: '新分值', dataIndex: 'val_curr', key: 'val_curr', width: 100 },
{
colKey: 'event_time',
title: '发生时间',
dataIndex: 'event_time',
key: 'event_time',
width: 180,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
render: (time: string) => new Date(time).toLocaleString()
}
]
@@ -57,14 +58,12 @@ export const EventHistory: React.FC = () => {
<div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="uuid"
loading={loading}
bordered
hover
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
</div>
+57 -116
View File
@@ -1,17 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
Card,
Space,
Button,
Tag,
Input,
Select,
Dialog,
MessagePlugin,
InputNumber,
Divider
} from 'tdesign-react'
import { SearchIcon, DeleteIcon } from 'tdesign-icons-react'
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
import { match, pinyin } from 'pinyin-pro'
interface student {
@@ -38,34 +27,29 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [sortType, setSortType] = useState<SortType>('alphabet')
const [searchKeyword, setSearchKeyword] = useState('')
// 滚动容器引用
const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 操作框状态
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [operationVisible, setOperationVisible] = useState(false)
const [customScore, setCustomScore] = useState<number | undefined>(undefined)
const [reasonContent, setReasonContent] = useState('')
const [submitLoading, setSubmitLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
// 获取姓氏
const getSurname = (name: string) => {
if (!name) return ''
return name.charAt(0)
}
// 获取拼音首字母
const getFirstLetter = (name: string) => {
if (!name) return ''
const firstChar = name.charAt(0)
// 如果是英文字母
if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase()
// 如果是中文,转拼音
const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' })
return py ? py.toUpperCase() : '#'
}
@@ -102,13 +86,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchData])
// 获取展示用的文字
const getDisplayText = (name: string) => {
if (!name) return ''
return name.length > 2 ? name.substring(name.length - 2) : name
}
// 拼音匹配
const matchStudentName = useCallback((s: student, keyword: string) => {
const q0 = keyword.trim().toLowerCase()
if (!q0) return true
@@ -136,7 +118,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return false
}, [])
// 过滤和排序学生
const sortedStudents = useMemo(() => {
const filtered = students.filter((s) => matchStudentName(s, searchKeyword))
@@ -163,7 +144,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}, [students, searchKeyword, sortType, matchStudentName])
// 分组显示
const groupedStudents = useMemo(() => {
if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) {
return [{ key: 'all', students: sortedStudents }]
@@ -181,7 +161,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
.map(([key, students]) => ({ key, students }))
}, [sortedStudents, sortType, searchKeyword])
// 按分类分组的理由
const groupedReasons = useMemo(() => {
const groups: Record<string, reason[]> = {}
reasons.forEach((r) => {
@@ -196,7 +175,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
})
}, [reasons])
// 生成头像颜色
const getAvatarColor = (name: string) => {
const colors = [
'#FF6B6B',
@@ -218,7 +196,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return colors[index]
}
// 跳转到指定分组
const scrollToGroup = (key: string) => {
const element = groupRefs.current[key]
if (element) {
@@ -226,10 +203,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
// 打开操作框
const openOperation = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setSelectedStudent(student)
@@ -238,11 +214,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setOperationVisible(true)
}
// 核心提交逻辑
const performSubmit = async (student: student, delta: number, content: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
@@ -254,29 +229,26 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
})
if (res.success) {
MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
messageApi.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
setOperationVisible(false)
// 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置
setStudents((prev) =>
prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s))
)
// 通知其他组件数据已更新(但不在此处重复 fetchData)
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '提交失败')
messageApi.error(res.message || '提交失败')
}
setSubmitLoading(false)
}
// 手动点击确定按钮提交(用于自定义分值)
const handleSubmit = async () => {
if (!selectedStudent) return
const delta = customScore
if (delta === undefined || !Number.isFinite(delta)) {
MessagePlugin.warning('请选择或输入分值')
messageApi.warning('请选择或输入分值')
return
}
@@ -284,18 +256,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
await performSubmit(selectedStudent, delta, content)
}
// 快捷理由选择:点击即提交
const handleReasonSelect = (reason: reason) => {
if (!selectedStudent) return
performSubmit(selectedStudent, reason.delta, reason.content)
}
// 渲染学生卡片
const renderStudentCard = (student: student, index: number) => {
const avatarText = getDisplayText(student.name)
const avatarColor = getAvatarColor(student.name)
// 排行榜勋章
let rankBadge: string | null = null
if (sortType === 'score' && !searchKeyword) {
if (index === 0) rankBadge = '🥇'
@@ -316,6 +285,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)',
overflow: 'visible'
}}
styles={{ body: { padding: '12px' } }}
>
{rankBadge && (
<div
@@ -364,9 +334,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
<Tag
theme={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
variant="light-outline"
size="small"
color={student.score > 0 ? 'success' : student.score < 0 ? 'error' : 'default'}
style={{ fontWeight: 'bold' }}
>
{student.score > 0 ? `+${student.score}` : student.score}
@@ -379,7 +347,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)
}
// 渲染分组学生卡片
const renderGroupedCards = () => {
return groupedStudents.map((group) => (
<div
@@ -399,11 +366,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
display: 'flex',
alignItems: 'center',
gap: '8px',
borderLeft: '4px solid var(--td-brand-color)',
borderLeft: '4px solid var(--ant-color-primary, #1890ff)',
paddingLeft: '12px'
}}
>
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span>
<span style={{ color: 'var(--ant-color-primary, #1890ff)' }}>{group.key}</span>
<span
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
>
@@ -424,7 +391,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
))
}
// 快速导航滑动处理
const navContainerRef = useRef<HTMLDivElement>(null)
const isNavDragging = useRef(false)
@@ -437,7 +403,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const itemCount = items.length
if (itemCount === 0) return
// 计算当前指向第几个项
const itemHeight = rect.height / itemCount
const index = Math.floor(y / itemHeight)
const safeIndex = Math.max(0, Math.min(itemCount - 1, index))
@@ -469,7 +434,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
document.removeEventListener('mouseup', onGlobalMouseUp)
}
// 触摸事件处理
const onNavTouchStart = (e: React.TouchEvent) => {
isNavDragging.current = true
if (e.touches[0]) {
@@ -480,7 +444,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const onNavTouchMove = (e: React.TouchEvent) => {
if (isNavDragging.current && e.touches[0]) {
handleNavAction(e.touches[0].clientY)
// 防止触摸滑动时触发页面滚动
if (e.cancelable) e.preventDefault()
}
}
@@ -489,7 +452,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
isNavDragging.current = false
}
// 渲染快速导航
const renderQuickNav = () => {
if (
groupedStudents.length <= 1 ||
@@ -521,7 +483,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)',
cursor: 'pointer',
userSelect: 'none',
touchAction: 'none' // 关键:禁用浏览器的默认触摸处理
touchAction: 'none'
}}
>
{groupedStudents.map((group) => (
@@ -535,9 +497,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
color: 'var(--td-brand-color)',
color: 'var(--ant-color-primary, #1890ff)',
borderRadius: '50%',
pointerEvents: 'none' // 让事件由父容器统一处理
pointerEvents: 'none'
}}
>
{group.key}
@@ -549,7 +511,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
{/* 顶部工具栏 */}
{contextHolder}
<div
style={{
marginBottom: '32px',
@@ -569,35 +531,31 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</p>
</div>
<Space size="medium">
{/* 搜索 */}
<Space size="middle">
<Input
value={searchKeyword}
onChange={setSearchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索姓名/拼音..."
prefixIcon={<SearchIcon />}
clearable
prefix={<SearchOutlined />}
allowClear
style={{ width: '220px' }}
/>
{/* 排序方式 */}
<Select
value={sortType}
onChange={(v) => setSortType(v as SortType)}
style={{ width: '140px' }}
autoWidth
>
<Select.Option value="alphabet" label="姓名排序" />
<Select.Option value="surname" label="姓氏分组" />
<Select.Option value="score" label="积分排行" />
</Select>
options={[
{ value: 'alphabet', label: '姓名排序' },
{ value: 'surname', label: '姓氏分组' },
{ value: 'score', label: '积分排行' }
]}
/>
</Space>
</div>
{/* 快速导航 */}
{renderQuickNav()}
{/* 学生卡片网格 */}
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
@@ -617,12 +575,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div>
{searchKeyword && (
<Button
variant="text"
theme="primary"
onClick={() => setSearchKeyword('')}
style={{ marginTop: '8px' }}
>
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
</Button>
)}
@@ -632,20 +585,19 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)}
</div>
{/* 操作框 */}
<Dialog
header={`积分操作:${selectedStudent?.name}`}
visible={operationVisible}
onClose={() => setOperationVisible(false)}
onConfirm={handleSubmit}
confirmBtn={{ content: '提交操作', loading: submitLoading }}
width="560px"
destroyOnClose
top="10%"
<Modal
title={`积分操作:${selectedStudent?.name}`}
open={operationVisible}
onCancel={() => setOperationVisible(false)}
onOk={handleSubmit}
confirmLoading={submitLoading}
okText="提交操作"
cancelText="取消"
width={560}
destroyOnHidden
>
{selectedStudent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
{/* 当前状态 */}
<div
style={{
display: 'flex',
@@ -680,14 +632,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</span>
<Tag
theme={
color={
selectedStudent.score > 0
? 'success'
: selectedStudent.score < 0
? 'danger'
? 'error'
: 'default'
}
variant="light"
style={{ fontWeight: 'bold' }}
>
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
@@ -695,7 +646,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
</div>
{/* 快捷理由 */}
{groupedReasons.length > 0 && (
<div>
<div
@@ -731,19 +681,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
>
{category}
</div>
<Space breakLine size="small">
<Space wrap size="small">
{items.map((r) => (
<Button
key={r.id}
variant="outline"
size="small"
onClick={() => handleReasonSelect(r)}
style={{
borderColor:
r.delta > 0
? 'var(--td-success-color-3)'
? 'var(--ant-color-success, #52c41a)'
: r.delta < 0
? 'var(--td-error-color-3)'
? 'var(--ant-color-error, #ff4d4f)'
: undefined
}}
>
@@ -753,9 +702,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
marginLeft: '4px',
color:
r.delta > 0
? 'var(--td-success-color)'
? 'var(--ant-color-success, #52c41a)'
: r.delta < 0
? 'var(--td-error-color)'
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit',
fontWeight: 'bold'
}}
@@ -771,7 +720,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
)}
{/* 自定义分值 */}
<div>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -784,20 +732,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Button
key={num}
size="small"
variant={customScore === num ? 'base' : 'outline'}
theme={num > 0 ? 'success' : 'danger'}
type={customScore === num ? 'primary' : 'default'}
danger={num < 0}
onClick={() => setCustomScore(num)}
style={{ minWidth: '42px' }}
>
{num > 0 ? `+${num}` : num}
</Button>
))}
<Button
size="small"
variant="outline"
onClick={() => setCustomScore(0)}
style={{ minWidth: '42px' }}
>
<Button size="small" onClick={() => setCustomScore(0)} style={{ minWidth: '42px' }}>
0
</Button>
</div>
@@ -817,7 +760,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
</div>
{/* 理由内容 */}
<div>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -827,12 +769,12 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
<Input
value={reasonContent}
onChange={setReasonContent}
onChange={(e) => setReasonContent(e.target.value)}
placeholder="输入加分/扣分的原因(可选)"
suffixIcon={
suffix={
reasonContent ? (
<DeleteIcon
onClick={() => setSearchKeyword('')}
<DeleteOutlined
onClick={() => setReasonContent('')}
style={{ cursor: 'pointer' }}
/>
) : undefined
@@ -840,19 +782,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
/>
</div>
{/* 变动预览 */}
{customScore !== undefined && (
<div
style={{
padding: '16px',
backgroundColor:
customScore > 0
? 'var(--td-success-color-1)'
? 'var(--ant-color-success-bg, #f6ffed)'
: customScore < 0
? 'var(--td-error-color-1)'
? 'var(--ant-color-error-bg, #fff2f0)'
: 'var(--ss-bg-color)',
borderRadius: '8px',
border: `1px solid ${customScore > 0 ? 'var(--td-success-color-2)' : customScore < 0 ? 'var(--td-error-color-2)' : 'var(--ss-border-color)'}`,
border: `1px solid ${customScore > 0 ? 'var(--ant-color-success-border, #b7eb8f)' : customScore < 0 ? 'var(--ant-color-error-border, #ffccc7)' : 'var(--ss-border-color)'}`,
marginTop: '4px'
}}
>
@@ -873,9 +814,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fontWeight: 'bold',
color:
customScore > 0
? 'var(--td-success-color)'
? 'var(--ant-color-success, #52c41a)'
: customScore < 0
? 'var(--td-error-color)'
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit'
}}
>
@@ -890,7 +831,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)}
</div>
)}
</Dialog>
</Modal>
</div>
)
}
+38 -56
View File
@@ -1,16 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Tag,
Button,
Select,
Space,
Card,
MessagePlugin,
Dialog
} from 'tdesign-react'
import { ViewListIcon, DownloadIcon } from 'tdesign-icons-react'
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
import * as XLSX from 'xlsx'
interface studentRank {
@@ -28,6 +19,7 @@ export const Leaderboard: React.FC = () => {
const [historyVisible, setHistoryVisible] = useState(false)
const [historyHeader, setHistoryHeader] = useState('')
const [historyText, setHistoryText] = useState('')
const [messageApi, contextHolder] = message.useMessage()
const fetchRankings = useCallback(async () => {
if (!(window as any).api) return
@@ -66,7 +58,7 @@ export const Leaderboard: React.FC = () => {
startTime
})
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
@@ -82,7 +74,6 @@ export const Leaderboard: React.FC = () => {
}
const handleExport = () => {
// 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
setTimeout(() => {
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
@@ -125,18 +116,18 @@ export const Leaderboard: React.FC = () => {
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
MessagePlugin.success('导出成功')
messageApi.success('导出成功')
}, 0)
}
const columns: PrimaryTableCol<studentRank>[] = [
const columns: ColumnsType<studentRank> = [
{
colKey: 'rank',
title: '排名',
key: 'rank',
width: 70,
align: 'center',
cell: ({ rowIndex }) => {
const rank = rowIndex + 1
render: (_, __, index) => {
const rank = index + 1
let color = 'inherit'
if (rank === 1) color = '#FFD700'
if (rank === 2) color = '#C0C0C0'
@@ -148,40 +139,34 @@ export const Leaderboard: React.FC = () => {
)
}
},
{ colKey: 'name', title: '姓名', width: 120, align: 'center' },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120, align: 'center' },
{
colKey: 'score',
title: '总积分',
dataIndex: 'score',
key: 'score',
width: 100,
align: 'center',
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
},
{
colKey: 'range_change',
title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
dataIndex: 'range_change',
key: 'range_change',
width: 100,
align: 'center',
cell: ({ row }) => (
<Tag
theme={row.range_change > 0 ? 'success' : row.range_change < 0 ? 'danger' : 'default'}
variant="light"
>
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
render: (change: number) => (
<Tag color={change > 0 ? 'success' : change < 0 ? 'error' : 'default'}>
{change > 0 ? `+${change}` : change}
</Tag>
)
},
{
colKey: 'operation',
title: '操作记录',
key: 'operation',
width: 100,
align: 'center',
cell: ({ row }) => (
<Button
variant="text"
theme="primary"
icon={<ViewListIcon />}
onClick={() => handleViewHistory(row.name)}
>
render: (_, row) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
</Button>
)
@@ -190,6 +175,7 @@ export const Leaderboard: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div
style={{
marginBottom: '24px',
@@ -202,14 +188,15 @@ export const Leaderboard: React.FC = () => {
<Space>
<Select
value={timeRange}
onChange={(v) => setTimeRange(v as string)}
onChange={(v) => setTimeRange(v)}
style={{ width: '120px' }}
>
<Select.Option value="today" label="今天" />
<Select.Option value="week" label="本周" />
<Select.Option value="month" label="本月" />
</Select>
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExport}>
options={[
{ value: 'today', label: '今天' },
{ value: 'week', label: '本周' },
{ value: 'month', label: '本月' }
]}
/>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
XLSX
</Button>
</Space>
@@ -217,27 +204,22 @@ export const Leaderboard: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
hover
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
className="ss-table-center"
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
<Dialog
header={historyHeader}
visible={historyVisible}
<Modal
title={historyHeader}
open={historyVisible}
onCancel={() => setHistoryVisible(false)}
footer={<Button onClick={() => setHistoryVisible(false)}></Button>}
width="80%"
cancelBtn={null}
confirmBtn="关闭"
onClose={() => setHistoryVisible(false)}
onConfirm={() => setHistoryVisible(false)}
>
<div
style={{
@@ -254,7 +236,7 @@ export const Leaderboard: React.FC = () => {
>
{historyText}
</div>
</Dialog>
</Modal>
</div>
)
}
+68 -103
View File
@@ -1,15 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Button,
Space,
Dialog,
Form,
Input,
MessagePlugin,
Tag
} from 'tdesign-react'
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface reason {
id: number
@@ -23,10 +14,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<reason[]>([])
const [loading, setLoading] = useState(false)
const [visible, setVisible] = useState(false)
const [deleteDialogVisible, setDeleteDialogVisible] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<reason | null>(null)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
@@ -60,15 +49,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const values = form.getFieldsValue?.(true) as any
const values = await form.validateFields()
const content = values.content?.trim()
const category = values.category?.trim() || '其他'
if (data.some((r) => r.content === content && r.category === category)) {
MessagePlugin.warning('该分类下已存在相同理由')
messageApi.warning('该分类下已存在相同理由')
return
}
@@ -79,148 +68,124 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
delta: Number(values.delta)
})
if (res.success) {
MessagePlugin.success('添加成功')
messageApi.success('添加成功')
setVisible(false)
form.reset()
form.resetFields()
fetchReasons()
emitDataUpdated('reasons')
} else {
MessagePlugin.error(res.message || '添加失败')
messageApi.error(res.message || '添加失败')
}
}
const handleDelete = async (row: reason) => {
const handleDelete = async (id: number) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setDeleteTarget(row)
setDeleteDialogVisible(true)
const res = await (window as any).api.deleteReason(id)
if (res.success) {
messageApi.success('删除成功')
fetchReasons()
emitDataUpdated('reasons')
} else {
messageApi.error(res.message || '删除失败')
}
}
const columns: PrimaryTableCol<reason>[] = [
const columns: ColumnsType<reason> = [
{
colKey: 'category',
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
cell: ({ row }) => <Tag variant="outline">{row.category}</Tag>
render: (category: string) => <Tag>{category}</Tag>
},
{ colKey: 'content', title: '理由内容', width: 250 },
{ title: '理由内容', dataIndex: 'content', key: 'content', width: 250 },
{
colKey: 'delta',
title: '预设分值',
dataIndex: 'delta',
key: 'delta',
width: 100,
cell: ({ row }) => (
render: (delta: number) => (
<span
style={{ color: row.delta > 0 ? 'var(--td-success-color)' : 'var(--td-error-color)' }}
style={{
color:
delta > 0 ? 'var(--ant-color-success, #52c41a)' : 'var(--ant-color-error, #ff4d4f)'
}}
>
{row.delta > 0 ? `+${row.delta}` : row.delta}
{delta > 0 ? `+${delta}` : delta}
</span>
)
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
<Space>
<Button
theme="danger"
variant="text"
render: (_, row) => (
<Popconfirm
title="确认删除该理由?"
onConfirm={() => handleDelete(row.id)}
disabled={!canEdit}
onClick={() => handleDelete(row)}
>
<Button type="link" danger disabled={!canEdit}>
</Button>
</Space>
</Popconfirm>
)
}
]
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</div>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
hover
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
<Dialog
header="添加理由"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
<Modal
title="添加理由"
open={visible}
onOk={handleAdd}
onCancel={() => setVisible(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="分类" name="category" initialData="其他">
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label="分类" name="category" initialValue="其他">
<Input placeholder="例如: 学习, 纪律" />
</Form.FormItem>
<Form.FormItem label="理由内容" name="content">
<Input placeholder="请输入理由" />
</Form.FormItem>
<Form.FormItem label="预设分值" name="delta">
<Input type="number" placeholder="例如: 2 或 -2" />
</Form.FormItem>
</Form>
</Dialog>
<Dialog
header="确认删除该理由?"
visible={deleteDialogVisible}
confirmBtn="删除"
confirmLoading={deleteLoading}
onClose={() => {
if (!deleteLoading) {
setDeleteDialogVisible(false)
setDeleteTarget(null)
}
}}
onCancel={() => {
if (!deleteLoading) {
setDeleteDialogVisible(false)
setDeleteTarget(null)
}
}}
onConfirm={async () => {
if (!(window as any).api) return
if (!deleteTarget) return
setDeleteLoading(true)
const res = await (window as any).api.deleteReason(deleteTarget.id)
setDeleteLoading(false)
if (res.success) {
MessagePlugin.success('删除成功')
setDeleteDialogVisible(false)
setDeleteTarget(null)
fetchReasons()
emitDataUpdated('reasons')
} else {
MessagePlugin.error(res.message || '删除失败')
}
}}
</Form.Item>
<Form.Item
label="理由内容"
name="content"
rules={[{ required: true, message: '请输入理由内容' }]}
>
<div style={{ wordBreak: 'break-all' }}>
{deleteTarget
? `${deleteTarget.category} / ${deleteTarget.content} (${
deleteTarget.delta > 0 ? `+${deleteTarget.delta}` : deleteTarget.delta
})`
: ''}
</div>
</Dialog>
<Input placeholder="请输入理由" />
</Form.Item>
<Form.Item
label="预设分值"
name="delta"
rules={[{ required: true, message: '请输入预设分值' }]}
>
<InputNumber placeholder="例如: 2 或 -2" style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+53 -68
View File
@@ -6,16 +6,15 @@ import {
Input,
InputNumber,
Button,
MessagePlugin,
message,
Card,
Collapse,
Table,
PrimaryTableCol,
Tag,
Space,
Popconfirm
} from 'tdesign-react'
import { RollbackIcon } from 'tdesign-icons-react'
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { UndoOutlined } from '@ant-design/icons'
import { match } from 'pinyin-pro'
const normalizeSearch = (input: unknown) =>
@@ -86,6 +85,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [loading, setLoading] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
@@ -93,7 +93,6 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchData = useCallback(async () => {
if (!(window as any).api) return
// 使用 setTimeout 避免 UI 阻塞
setTimeout(async () => {
setLoading(true)
try {
@@ -134,17 +133,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleSubmit = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const values = form.getFieldsValue(true) as any
// 支持多选学生
const studentNames = Array.isArray(values.student_name)
? values.student_name
: [values.student_name]
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
MessagePlugin.warning('请填写完整信息')
messageApi.warning('请填写完整信息')
return
}
@@ -155,7 +153,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
if (!hasDeltaInput && !selectedReason) {
MessagePlugin.warning('请填写分值或选择预设理由')
messageApi.warning('请填写分值或选择预设理由')
return
}
@@ -166,7 +164,6 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
: Math.abs(deltaInput)
: Number(selectedReason?.delta ?? 0)
// 为每个选中的学生创建事件
try {
let successCount = 0
for (const studentName of studentNames) {
@@ -181,7 +178,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
if (successCount === studentNames.length) {
MessagePlugin.success(`已为 ${successCount} 名学生提交积分`)
messageApi.success(`已为 ${successCount} 名学生提交积分`)
form.setFieldsValue({
student_name: [],
delta: undefined,
@@ -192,7 +189,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
fetchData()
emitDataUpdated('events')
}
@@ -204,48 +201,48 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleUndo = async (uuid: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const res = await (window as any).api.deleteEvent(uuid)
if (res.success) {
MessagePlugin.success('已撤销操作')
messageApi.success('已撤销操作')
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '撤销失败')
messageApi.error(res.message || '撤销失败')
}
}
const columns: PrimaryTableCol<scoreEvent>[] = [
{ colKey: 'student_name', title: '学生', width: 100 },
const columns: ColumnsType<scoreEvent> = [
{ title: '学生', dataIndex: 'student_name', key: 'student_name', width: 100 },
{
colKey: 'delta',
title: '变动',
dataIndex: 'delta',
key: 'delta',
width: 80,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
render: (delta: number) => (
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
)
},
{ colKey: 'reason_content', title: '理由', ellipsis: true },
{ title: '理由', dataIndex: 'reason_content', key: 'reason_content', ellipsis: true },
{
colKey: 'event_time',
title: '时间',
dataIndex: 'event_time',
key: 'event_time',
width: 160,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
render: (time: string) => new Date(time).toLocaleString()
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 80,
cell: ({ row }) => (
render: (_, row) => (
<Popconfirm
content="确定要撤销这条记录吗?学生积分将回滚。"
title="确定要撤销这条记录吗?学生积分将回滚。"
onConfirm={() => handleUndo(row.uuid)}
>
<Button variant="text" theme="warning" disabled={!canEdit} icon={<RollbackIcon />}>
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
</Button>
</Popconfirm>
@@ -255,43 +252,37 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form
form={form}
labelWidth={80}
initialData={{ type: 'add' }}
onReset={() => form.setFieldsValue({ type: 'add' })}
>
<Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem label="姓名" name="student_name">
<Form.Item label="姓名" name="student_name">
<Select
filterable
multiple
mode="multiple"
showSearch
placeholder="请选择或搜索学生"
filter={(filterWords, option) =>
matchStudentName(getOptionLabel(option), filterWords)
}
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
options={students.map((s) => ({ label: s.name, value: s.name }))}
/>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="分数">
<Form.Item label="分数">
<Space>
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
<Radio.Group variant="default-filled">
<Form.Item name="type" noStyle>
<Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="add"></Radio.Button>
<Radio.Button value="subtract"></Radio.Button>
</Radio.Group>
</Form.FormItem>
<Form.FormItem name="delta" style={{ marginBottom: 0 }}>
</Form.Item>
<Form.Item name="delta" noStyle>
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
</Form.FormItem>
</Form.Item>
</Space>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="快捷理由" name="reason_id">
<Form.Item label="快捷理由" name="reason_id">
<Select
placeholder="选择预设理由"
onChange={(v) => {
@@ -317,23 +308,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
type: reason.delta > 0 ? 'add' : 'subtract'
})
}}
>
{reasons.map((r) => (
<Select.Option key={r.id} value={r.id} label={r.content}>
{r.content} ({r.delta > 0 ? `+${r.delta}` : r.delta})
</Select.Option>
))}
</Select>
</Form.FormItem>
options={reasons.map((r) => ({
label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
value: r.id
}))}
/>
</Form.Item>
<Form.FormItem label="理由内容" name="reason_content">
<Form.Item label="理由内容" name="reason_content">
<Input placeholder="手动输入或选择快捷理由" />
</Form.FormItem>
</Form.Item>
</div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
<Button
theme="primary"
type="primary"
size="large"
disabled={!canEdit}
onClick={handleSubmit}
@@ -347,20 +336,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Card>
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Collapse defaultValue={[]} expandMutex>
<Collapse.Panel header="最近记录" value="recent">
<div style={{ fontWeight: 600, marginBottom: 16 }}></div>
<Table
data={events}
dataSource={events}
columns={columns}
rowKey="uuid"
loading={loading}
size="small"
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual' }}
style={{ color: 'var(--ss-text-main)' }}
/>
</Collapse.Panel>
</Collapse>
</Card>
</div>
)
+340 -218
View File
@@ -1,17 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Tabs,
Card,
Form,
Select,
Input,
Button,
Space,
Divider,
Tag,
Dialog,
MessagePlugin
} from 'tdesign-react'
import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd'
import { useTheme } from '../contexts/ThemeContext'
import { useThemeEditor } from '../contexts/ThemeEditorContext'
@@ -63,12 +51,21 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
const canAdmin = permission === 'admin'
const [messageApi, contextHolder] = message.useMessage()
const [pgConnectionString, setPgConnectionString] = useState('')
const [pgConnectionStatus, setPgConnectionStatus] = useState<{
connected: boolean
type: 'sqlite' | 'postgresql'
error?: string
}>({ connected: true, type: 'sqlite' })
const [pgTestLoading, setPgTestLoading] = useState(false)
const [pgSwitchLoading, setPgSwitchLoading] = useState(false)
const permissionTag = useMemo(() => {
return (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
@@ -84,6 +81,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.getAllSettings()
if (res.success && res.data) {
setSettings(res.data)
setPgConnectionString(res.data.pg_connection_string || '')
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: 'sqlite' })
}
const authRes = await (window as any).api.authGetStatus()
if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
@@ -114,7 +113,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.queryLogs(200)
setLogsLoading(false)
if (!res.success) {
MessagePlugin.error(res.message || '读取日志失败')
messageApi.error(res.message || '读取日志失败')
return
}
setLogsText((res.data || []).join('\n'))
@@ -125,12 +124,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return
const res = await (window as any).api.queryLogs(5000)
if (!res.success) {
MessagePlugin.error(res.message || '读取日志失败')
messageApi.error(res.message || '读取日志失败')
return
}
const dateTime = new Date().toISOString().replace(/[:.]/g, '-')
downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`)
MessagePlugin.success('日志已导出')
messageApi.success('日志已导出')
}
const downloadTextFile = (filename: string, text: string) => {
@@ -156,7 +155,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return
const res = await (window as any).api.exportDataJson()
if (!res.success || !res.data) {
MessagePlugin.error(res.message || '导出失败')
messageApi.error(res.message || '导出失败')
return
}
const blob = new Blob([res.data], { type: 'application/json;charset=utf-8' })
@@ -166,7 +165,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
MessagePlugin.success('导出成功')
messageApi.success('导出成功')
}
const importJson = async (file: File) => {
@@ -174,10 +173,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const text = await file.text()
const res = await (window as any).api.importDataJson(text)
if (res.success) {
MessagePlugin.success('导入成功,正在刷新')
messageApi.success('导入成功,正在刷新')
setTimeout(() => window.location.reload(), 300)
} else {
MessagePlugin.error(res.message || '导入失败')
messageApi.error(res.message || '导入失败')
}
}
@@ -194,10 +193,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (res.data?.recoveryString) {
showRecoveryDialog('找回字符串(请妥善保存)', res.data.recoveryString)
} else {
MessagePlugin.success('密码已更新')
messageApi.success('密码已更新')
}
} else {
MessagePlugin.error(res.message || '更新失败')
messageApi.error(res.message || '更新失败')
}
}
@@ -205,7 +204,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return
const res = await (window as any).api.authGenerateRecovery()
if (!res.success || !res.data?.recoveryString) {
MessagePlugin.error(res.message || '生成失败')
messageApi.error(res.message || '生成失败')
return
}
await loadAll()
@@ -216,7 +215,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return
const res = await (window as any).api.authResetByRecovery(recoveryToReset)
if (!res.success || !res.data?.recoveryString) {
MessagePlugin.error(res.message || '重置失败')
messageApi.error(res.message || '重置失败')
return
}
setRecoveryToReset('')
@@ -225,7 +224,6 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
}
const clearAllPasswords = () => {
if (!(window as any).api) return
setClearDialogVisible(true)
}
@@ -235,63 +233,103 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.authClearAll()
setClearLoading(false)
if (res.success) {
MessagePlugin.success('已清空')
messageApi.success('已清空')
await loadAll()
setClearDialogVisible(false)
} else {
MessagePlugin.error(res.message || '清空失败')
messageApi.error(res.message || '清空失败')
}
}
const confirmSettlement = () => {
if (!(window as any).api) return
setSettleDialogVisible(true)
}
const currentYear = new Date().getFullYear()
return (
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
{permissionTag}
</div>
const testPgConnection = async () => {
if (!(window as any).api) return
if (!pgConnectionString) {
messageApi.warning('请输入 PostgreSQL 连接字符串')
return
}
setPgTestLoading(true)
try {
const res = await (window as any).api.dbTestConnection(pgConnectionString)
if (res.success && res.data?.success) {
messageApi.success('连接测试成功')
} else {
messageApi.error(res.data?.error || res.message || '连接测试失败')
}
} catch (e: any) {
messageApi.error(e?.message || '连接测试失败')
} finally {
setPgTestLoading(false)
}
}
<Tabs value={activeTab} onChange={(v) => setActiveTab(v as string)}>
<Tabs.TabPanel value="appearance" label="外观">
const switchToPg = async () => {
if (!(window as any).api) return
setPgSwitchLoading(true)
try {
const res = await (window as any).api.dbSwitchConnection(pgConnectionString)
if (res.success) {
messageApi.success(
`已切换到 ${res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'} 数据库`
)
await loadAll()
} else {
messageApi.error(res.message || '切换失败')
}
} catch (e: any) {
messageApi.error(e?.message || '切换失败')
} finally {
setPgSwitchLoading(false)
}
}
const switchToSQLite = async () => {
if (!(window as any).api) return
setPgSwitchLoading(true)
try {
const res = await (window as any).api.dbSwitchConnection('')
if (res.success) {
messageApi.success('已切换到本地 SQLite 数据库')
setPgConnectionString('')
await loadAll()
} else {
messageApi.error(res.message || '切换失败')
}
} catch (e: any) {
messageApi.error(e?.message || '切换失败')
} finally {
setPgSwitchLoading(false)
}
}
const currentYear = new Date().getFullYear()
const tabItems = [
{
key: 'appearance',
label: '外观',
children: (
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<Form labelWidth={120}>
<Form.FormItem label="当前主题">
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="当前主题">
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
style={{ width: '200px' }}
>
{themes.map((t) => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
<Button
variant="outline"
theme="default"
onClick={() => startEditing(currentTheme || undefined)}
>
</Button>
<Button variant="text" theme="primary" onClick={() => startEditing()}>
options={themes.map((t) => ({ value: t.id, label: t.name }))}
/>
<Button onClick={() => startEditing(currentTheme || undefined)}></Button>
<Button type="link" onClick={() => startEditing()}>
</Button>
</div>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="界面缩放">
<Form.Item label="界面缩放">
<Select
value={settings.window_zoom || '1.0'}
onChange={async (v) => {
@@ -300,34 +338,39 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.setSetting('window_zoom', next)
if (res.success) {
setSettings((prev) => ({ ...prev, window_zoom: next }))
MessagePlugin.success('界面缩放已更新')
messageApi.success('界面缩放已更新')
} else {
MessagePlugin.error(res.message || '更新失败')
messageApi.error(res.message || '更新失败')
}
}}
style={{ width: '320px' }}
disabled={!canAdmin}
>
<Select.Option value="0.7" label="70% (较小)" />
<Select.Option value="0.8" label="80%" />
<Select.Option value="0.9" label="90%" />
<Select.Option value="1.0" label="100% (默认)" />
<Select.Option value="1.1" label="110%" />
<Select.Option value="1.2" label="120%" />
<Select.Option value="1.3" label="130%" />
<Select.Option value="1.5" label="150% (较大)" />
</Select>
options={[
{ value: '0.7', label: '70% (较小)' },
{ value: '0.8', label: '80%' },
{ value: '0.9', label: '90%' },
{ value: '1.0', label: '100% (默认)' },
{ value: '1.1', label: '110%' },
{ value: '1.2', label: '120%' },
{ value: '1.3', label: '130%' },
{ value: '1.5', label: '150% (较大)' }
]}
/>
<div
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
>
</div>
</Form.FormItem>
</Form.Item>
</Form>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="security" label="安全">
)
},
{
key: 'security',
label: '安全',
children: (
<>
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
@@ -338,22 +381,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontWeight: 600 }}></div>
<Space>
<Tag
theme={securityStatus?.hasAdminPassword ? 'success' : 'default'}
variant="light"
>
<Tag color={securityStatus?.hasAdminPassword ? 'success' : 'default'}>
{securityStatus?.hasAdminPassword ? '已设置' : '未设置'}
</Tag>
<Tag
theme={securityStatus?.hasPointsPassword ? 'success' : 'default'}
variant="light"
>
<Tag color={securityStatus?.hasPointsPassword ? 'success' : 'default'}>
{securityStatus?.hasPointsPassword ? '已设置' : '未设置'}
</Tag>
<Tag
theme={securityStatus?.hasRecoveryString ? 'success' : 'default'}
variant="light"
>
<Tag color={securityStatus?.hasRecoveryString ? 'success' : 'default'}>
{securityStatus?.hasRecoveryString ? '已生成' : '未生成'}
</Tag>
</Space>
@@ -361,53 +395,47 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<Divider />
<Form labelWidth={120}>
<Form.FormItem label="管理密码">
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="管理密码">
<Input
value={adminPassword}
onChange={(v) => setAdminPassword(v)}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="输入6位数字(留空则不修改)"
maxlength={6}
maxLength={6}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
/>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="积分密码">
<Form.Item label="积分密码">
<Input
value={pointsPassword}
onChange={(v) => setPointsPassword(v)}
onChange={(e) => setPointsPassword(e.target.value)}
placeholder="输入6位数字(留空则不修改)"
maxlength={6}
maxLength={6}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
/>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="操作">
<Form.Item label="操作">
<Space>
<Button
theme="primary"
type="primary"
onClick={savePasswords}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
>
</Button>
<Button
variant="outline"
onClick={generateRecovery}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
>
</Button>
<Button
theme="danger"
variant="outline"
onClick={clearAllPasswords}
disabled={!canAdmin}
>
<Button danger onClick={clearAllPasswords} disabled={!canAdmin}>
</Button>
</Space>
</Form.FormItem>
</Form.Item>
</Form>
</Card>
@@ -416,11 +444,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<Space>
<Input
value={recoveryToReset}
onChange={(v) => setRecoveryToReset(v)}
onChange={(e) => setRecoveryToReset(e.target.value)}
placeholder="输入找回字符串"
style={{ width: '420px' }}
/>
<Button theme="primary" variant="outline" onClick={resetByRecovery}>
<Button type="primary" onClick={resetByRecovery}>
</Button>
</Space>
@@ -428,9 +456,109 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
/
</div>
</Card>
</Tabs.TabPanel>
</>
)
},
{
key: 'database',
label: '数据库连接',
children: (
<>
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
color: 'var(--ss-text-main)',
marginBottom: '16px'
}}
>
<div style={{ fontWeight: 600, marginBottom: '12px' }}></div>
<Space>
<Tag color={pgConnectionStatus.type === 'postgresql' ? 'blue' : 'green'}>
{pgConnectionStatus.type === 'postgresql'
? 'PostgreSQL 远程数据库'
: 'SQLite 本地数据库'}
</Tag>
<Tag color={pgConnectionStatus.connected ? 'success' : 'error'}>
{pgConnectionStatus.connected ? '已连接' : '未连接'}
</Tag>
{pgConnectionStatus.error && (
<span style={{ color: 'var(--ant-color-error, #ff4d4f)', fontSize: '12px' }}>
{pgConnectionStatus.error}
</span>
)}
</Space>
</Card>
<Tabs.TabPanel value="data" label="数据管理">
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
color: 'var(--ss-text-main)',
marginBottom: '16px'
}}
>
<div style={{ fontWeight: 600, marginBottom: '12px' }}>PostgreSQL </div>
<div
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
>
PostgreSQL
</div>
<Space.Compact style={{ width: '100%', marginBottom: '12px' }}>
<Input
value={pgConnectionString}
onChange={(e) => setPgConnectionString(e.target.value)}
placeholder="postgresql://user:password@host:port/database?sslmode=require"
style={{ flex: 1 }}
disabled={!canAdmin}
/>
<Button
onClick={testPgConnection}
loading={pgTestLoading}
disabled={!canAdmin || !pgConnectionString}
>
</Button>
</Space.Compact>
<div
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
>
postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require
</div>
<Space>
<Button
type="primary"
onClick={switchToPg}
loading={pgSwitchLoading}
disabled={!canAdmin || !pgConnectionString}
>
PostgreSQL
</Button>
<Button
onClick={switchToSQLite}
loading={pgSwitchLoading}
disabled={!canAdmin || pgConnectionStatus.type === 'sqlite'}
>
SQLite
</Button>
</Space>
</Card>
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontWeight: 600, marginBottom: '12px' }}></div>
<div style={{ fontSize: '13px', color: 'var(--ss-text-secondary)', lineHeight: '1.8' }}>
<p> 使 PostgreSQL </p>
<p> </p>
<p> </p>
<p> 使 NeonSupabaseAWS RDS </p>
</div>
</Card>
</>
)
},
{
key: 'data',
label: '数据管理',
children: (
<>
<Card
title="结算"
style={{
@@ -441,8 +569,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
>
<Space align="center">
<Button
theme="danger"
variant="outline"
danger
disabled={!canAdmin}
loading={settleLoading}
onClick={confirmSettlement}
@@ -464,12 +591,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
>
<div style={{ fontWeight: 600, marginBottom: '12px' }}> / </div>
<Space>
<Button theme="primary" onClick={exportJson}>
<Button type="primary" onClick={exportJson}>
JSON
</Button>
<Button variant="outline" onClick={() => importInputRef.current?.click()}>
JSON
</Button>
<Button onClick={() => importInputRef.current?.click()}> JSON</Button>
<input
ref={importInputRef}
type="file"
@@ -491,8 +616,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
title="日志"
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
>
<Form labelWidth={120}>
<Form.FormItem label="日志级别">
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="日志级别">
<Select
value={settings.log_level}
onChange={async (v) => {
@@ -501,57 +626,55 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.setSetting('log_level', next)
if (res.success) {
setSettings((prev) => ({ ...prev, log_level: next }))
MessagePlugin.success('日志级别已更新')
messageApi.success('日志级别已更新')
} else {
MessagePlugin.error(res.message || '更新失败')
messageApi.error(res.message || '更新失败')
}
}}
style={{ width: '320px' }}
>
<Select.Option value="debug" label="DEBUG (调试)" />
<Select.Option value="info" label="INFO (信息)" />
<Select.Option value="warn" label="WARN (警告)" />
<Select.Option value="error" label="ERROR (错误)" />
</Select>
</Form.FormItem>
<Form.FormItem label="日志操作">
options={[
{ value: 'debug', label: 'DEBUG (调试)' },
{ value: 'info', label: 'INFO (信息)' },
{ value: 'warn', label: 'WARN (警告)' },
{ value: 'error', label: 'ERROR (错误)' }
]}
/>
</Form.Item>
<Form.Item label="日志操作">
<Space>
<Button variant="outline" loading={logsLoading} onClick={showLogs}>
<Button loading={logsLoading} onClick={showLogs}>
</Button>
<Button variant="outline" onClick={exportLogs}>
</Button>
<Button onClick={exportLogs}></Button>
<Button
theme="danger"
variant="outline"
danger
onClick={async () => {
if (!(window as any).api) return
const res = await (window as any).api.clearLogs()
if (res.success) MessagePlugin.success('日志已清空')
else MessagePlugin.error(res.message || '清空失败')
if (res.success) messageApi.success('日志已清空')
else messageApi.error(res.message || '清空失败')
}}
>
</Button>
</Space>
</Form.FormItem>
</Form.Item>
</Form>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="url" label="URL 链接">
</>
)
},
{
key: 'url',
label: 'URL 链接',
children: (
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
URL (secscore://)
</div>
<Divider />
<div
style={{
marginBottom: '12px',
fontSize: '13px',
color: 'var(--ss-text-secondary)'
}}
style={{ marginBottom: '12px', fontSize: '13px', color: 'var(--ss-text-secondary)' }}
>
URL SecScore
</div>
@@ -572,7 +695,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<Divider />
<Space>
<Button
theme="primary"
type="primary"
loading={urlRegisterLoading}
disabled={!canAdmin}
onClick={async () => {
@@ -581,28 +704,25 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const res = await (window as any).api.registerUrlProtocol()
setUrlRegisterLoading(false)
if (res && res.success) {
MessagePlugin.success('URL 协议已注册')
messageApi.success('URL 协议已注册')
} else {
MessagePlugin.error(res?.message || '注册失败')
messageApi.error(res?.message || '注册失败')
}
}}
>
URL
</Button>
</Space>
<div
style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--ss-text-secondary)'
}}
>
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
SecScore
</div>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="about" label="关于">
)
},
{
key: 'about',
label: '关于',
children: (
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>
<div style={{ color: 'var(--ss-text-secondary)', marginBottom: '16px' }}>
@@ -622,25 +742,18 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>IPC </div>
<div>
<Tag
theme={(window as any).api ? 'success' : 'danger'}
variant="light"
size="small"
>
<Tag color={(window as any).api ? 'success' : 'error'}>
{(window as any).api ? '已连接' : '未连接 (Preload 失败)'}
</Tag>
</div>
<div style={{ color: 'var(--ss-text-secondary)' }}></div>
<div>
<Tag variant="outline" size="small">
{import.meta.env.DEV ? 'Development' : 'Production'}
</Tag>
<Tag>{import.meta.env.DEV ? 'Development' : 'Production'}</Tag>
</div>
</div>
<Divider />
<div style={{ marginTop: '16px' }}>
<Button
variant="outline"
onClick={() => {
;(window as any).api?.toggleDevTools()
}}
@@ -649,17 +762,37 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Button>
</div>
</Card>
</Tabs.TabPanel>
</Tabs>
)
}
]
<Dialog
header={recoveryDialogHeader}
visible={recoveryDialogVisible}
return (
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
{contextHolder}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
{permissionTag}
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Modal
title={recoveryDialogHeader}
open={recoveryDialogVisible}
onCancel={() => setRecoveryDialogVisible(false)}
footer={
<Button type="primary" onClick={() => setRecoveryDialogVisible(false)}>
</Button>
}
width="70%"
cancelBtn={null}
confirmBtn="我已保存"
onClose={() => setRecoveryDialogVisible(false)}
onConfirm={() => setRecoveryDialogVisible(false)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div
@@ -673,7 +806,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</div>
<Space>
<Button
theme="primary"
type="primary"
onClick={() =>
downloadTextFile(
recoveryDialogFilename ||
@@ -689,16 +822,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
线
</div>
</div>
</Dialog>
</Modal>
<Dialog
header="系统日志 (最后200条)"
visible={logsDialogVisible}
<Modal
title="系统日志 (最后200条)"
open={logsDialogVisible}
onCancel={() => setLogsDialogVisible(false)}
footer={<Button onClick={() => setLogsDialogVisible(false)}></Button>}
width="80%"
cancelBtn={null}
confirmBtn="关闭"
onClose={() => setLogsDialogVisible(false)}
onConfirm={() => setLogsDialogVisible(false)}
>
<div
style={{
@@ -715,56 +846,47 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
>
{logsText || '暂无日志'}
</div>
</Dialog>
</Modal>
<Dialog
header="确认结算并重新开始?"
visible={settleDialogVisible}
confirmBtn="结算"
confirmLoading={settleLoading}
onClose={() => {
if (!settleLoading) setSettleDialogVisible(false)
}}
onCancel={() => {
if (!settleLoading) setSettleDialogVisible(false)
}}
onConfirm={async () => {
<Modal
title="确认结算并重新开始?"
open={settleDialogVisible}
onCancel={() => !settleLoading && setSettleDialogVisible(false)}
onOk={async () => {
if (!(window as any).api) return
setSettleLoading(true)
const res = await (window as any).api.createSettlement()
setSettleLoading(false)
if (res.success && res.data) {
MessagePlugin.success('结算成功,已重新开始积分')
messageApi.success('结算成功,已重新开始积分')
emitDataUpdated('all')
setSettleDialogVisible(false)
} else {
MessagePlugin.error(res.message || '结算失败')
messageApi.error(res.message || '结算失败')
}
}}
confirmLoading={settleLoading}
okText="结算"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div></div>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
"结算历史"
</div>
</div>
</Dialog>
</Modal>
<Dialog
header="确认清空所有密码?"
visible={clearDialogVisible}
confirmBtn="确认清空"
<Modal
title="确认清空所有密码?"
open={clearDialogVisible}
onCancel={() => !clearLoading && setClearDialogVisible(false)}
onOk={handleConfirmClearAll}
confirmLoading={clearLoading}
onClose={() => {
if (!clearLoading) setClearDialogVisible(false)
}}
onCancel={() => {
if (!clearLoading) setClearDialogVisible(false)
}}
onConfirm={handleConfirmClearAll}
okText="确认清空"
okButtonProps={{ danger: true }}
>
</Dialog>
</Modal>
</div>
)
}
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, MessagePlugin, Space, Table } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
import { Button, Card, message, Space, Table } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface settlementSummary {
id: number
@@ -26,6 +26,7 @@ export const SettlementHistory: React.FC = () => {
} | null>(null)
const [rows, setRows] = useState<settlementLeaderboardRow[]>([])
const [detailLoading, setDetailLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const formatRange = (s: { start_time: string; end_time: string }) => {
const start = new Date(s.start_time).toLocaleString()
@@ -39,7 +40,7 @@ export const SettlementHistory: React.FC = () => {
try {
const res = await (window as any).api.querySettlements()
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
setSettlements(res.data || [])
@@ -48,7 +49,7 @@ export const SettlementHistory: React.FC = () => {
} finally {
setLoading(false)
}
}, [])
}, [messageApi])
useEffect(() => {
fetchSettlements()
@@ -70,7 +71,7 @@ export const SettlementHistory: React.FC = () => {
try {
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
if (!res.success || !res.data) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
setSelectedSettlement(res.data.settlement)
@@ -82,21 +83,22 @@ export const SettlementHistory: React.FC = () => {
}
}
const columns: PrimaryTableCol<settlementLeaderboardRow>[] = useMemo(
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
() => [
{
colKey: 'rank',
title: '排名',
key: 'rank',
width: 70,
align: 'center',
cell: ({ rowIndex }) => <span style={{ fontWeight: 'bold' }}>{rowIndex + 1}</span>
render: (_, __, index) => <span style={{ fontWeight: 'bold' }}>{index + 1}</span>
},
{ colKey: 'name', title: '姓名', width: 160 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 160 },
{
colKey: 'score',
title: '阶段积分',
dataIndex: 'score',
key: 'score',
width: 120,
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
}
],
[]
@@ -105,9 +107,9 @@ export const SettlementHistory: React.FC = () => {
if (selectedId !== null && selectedSettlement) {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<Space style={{ marginBottom: '16px' }}>
<Button
variant="outline"
onClick={() => {
setSelectedId(null)
setSelectedSettlement(null)
@@ -124,14 +126,12 @@ export const SettlementHistory: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={rows}
dataSource={rows}
columns={columns}
rowKey="name"
loading={detailLoading}
bordered
hover
pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
@@ -141,6 +141,7 @@ export const SettlementHistory: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<div
style={{
@@ -161,7 +162,7 @@ export const SettlementHistory: React.FC = () => {
{formatRange(s)}
</div>
<Space>
<Button theme="primary" onClick={() => openSettlement(s.id)}>
<Button type="primary" onClick={() => openSettlement(s.id)}>
</Button>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
+220 -40
View File
@@ -1,33 +1,169 @@
import { Layout, Menu } from 'tdesign-react'
import { Layout, Menu, Card, Tag, Button, Space, message } from 'antd'
import {
UserIcon,
SettingIcon,
HistoryIcon,
RootListIcon,
ViewListIcon,
HomeIcon,
ReplayIcon
} from 'tdesign-icons-react'
UserOutlined,
SettingOutlined,
HistoryOutlined,
UnorderedListOutlined,
HomeOutlined,
SyncOutlined,
FileTextOutlined,
CloudOutlined,
UploadOutlined
} from '@ant-design/icons'
import { useState, useEffect } from 'react'
import appLogo from '../assets/logoHD.svg'
const { Aside } = Layout
const { Sider } = Layout
interface SidebarProps {
activeMenu: string
permission: 'admin' | 'points' | 'view'
onMenuChange: (value: string | number) => void
onMenuChange: (value: string) => void
}
interface DbStatus {
type: 'sqlite' | 'postgresql'
connected: boolean
error?: string
}
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: 'sqlite', connected: true })
const [syncLoading, setSyncLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => {
loadDbStatus()
const handleStatusChange = () => {
loadDbStatus()
}
if ((window as any).api) {
const unsubscribe = (window as any).api.onSettingChanged((change) => {
if (change.key === 'pg_connection_status') {
handleStatusChange()
}
})
return unsubscribe
}
}, [])
const loadDbStatus = async () => {
if (!(window as any).api) return
try {
const res = await (window as any).api.dbGetStatus()
if (res.success && res.data) {
setDbStatus(res.data)
}
} catch (e) {
console.error('Failed to load database status:', e)
}
}
const handleSync = async () => {
if (!(window as any).api) return
setSyncLoading(true)
try {
await loadDbStatus()
} catch (e) {
console.error('Failed to sync database status:', e)
} finally {
setSyncLoading(false)
}
}
const [forceSyncLoading, setForceSyncLoading] = useState(false)
const handleForceSync = async () => {
if (!(window as any).api) return
const statusRes = await (window as any).api.dbGetStatus()
if (!statusRes.success || !statusRes.data) {
messageApi.error('获取数据库状态失败')
return
}
if (statusRes.data.type !== 'postgresql') {
messageApi.error('当前不是远程数据库模式,请重启应用后重试')
return
}
if (!statusRes.data.connected) {
messageApi.error('数据库未连接')
return
}
setForceSyncLoading(true)
try {
const res = await (window as any).api.dbSync()
if (res.success && res.data?.success) {
messageApi.success('同步成功')
} else {
messageApi.error(res.data?.message || res.message || '同步失败')
}
} catch (e: any) {
messageApi.error(e?.message || '同步失败')
} finally {
setForceSyncLoading(false)
}
}
const menuItems = [
{
key: 'home',
icon: <HomeOutlined />,
label: '主页'
},
{
key: 'students',
icon: <UserOutlined />,
label: '学生管理',
disabled: permission !== 'admin'
},
{
key: 'score',
icon: <HistoryOutlined />,
label: '积分管理'
},
{
key: 'auto-score',
icon: <SyncOutlined />,
label: '自动加分'
},
{
key: 'leaderboard',
icon: <UnorderedListOutlined />,
label: '排行榜'
},
{
key: 'settlements',
icon: <FileTextOutlined />,
label: '结算历史'
},
{
key: 'reasons',
icon: <UnorderedListOutlined />,
label: '理由管理',
disabled: permission !== 'admin'
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置',
disabled: permission !== 'admin'
}
]
return (
<Aside
<Sider
className="ss-sidebar"
width={200}
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)',
display: 'flex',
flexDirection: 'column'
}}
theme="light"
>
<div
style={
@@ -67,34 +203,78 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
</div>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
<Menu.MenuItem value="home" icon={<HomeIcon />}>
{' '}
</Menu.MenuItem>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="auto-score" icon={<ReplayIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
</Menu>
<Menu
mode="inline"
selectedKeys={[activeMenu]}
onClick={({ key }) => onMenuChange(key)}
style={{
width: '100%',
border: 'none',
backgroundColor: 'transparent'
}}
items={menuItems}
/>
</div>
</Aside>
{dbStatus.type === 'postgresql' && (
<Card
size="small"
style={{
margin: '8px',
backgroundColor: 'var(--ss-card-bg)',
border: '1px solid var(--ss-border-color)'
}}
bodyStyle={{ padding: '12px' }}
>
{contextHolder}
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space size={4}>
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
<span style={{ fontSize: '12px', fontWeight: 500 }}></span>
</Space>
<Tag
color={dbStatus.connected ? 'success' : 'error'}
style={{ margin: 0, fontSize: '10px' }}
>
{dbStatus.connected ? '已连接' : '未连接'}
</Tag>
</div>
<Button
type="text"
size="small"
icon={<SyncOutlined spin={syncLoading} />}
onClick={handleSync}
loading={syncLoading}
style={{
width: '100%',
height: '24px',
fontSize: '12px',
padding: '0 8px',
color: 'var(--ss-text-secondary)'
}}
>
</Button>
<Button
type="primary"
size="small"
icon={<UploadOutlined />}
onClick={handleForceSync}
loading={forceSyncLoading}
disabled={!dbStatus.connected}
style={{
width: '100%',
height: '24px',
fontSize: '12px',
padding: '0 8px'
}}
>
</Button>
</Space>
</Card>
)}
</Sider>
)
}
+109 -114
View File
@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Tag } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { TagEditorDialog } from './TagEditorDialog'
// 创建 XLSX Worker
const createXlsxWorker = () => {
return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
type: 'module'
@@ -35,8 +34,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
const xlsxWorkerRef = useRef<Worker | null>(null)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
// 初始化 Worker
useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker()
return () => {
@@ -106,36 +105,31 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
try {
const validateResult = await form.validate()
if (validateResult !== true) {
return
}
const values = form.getFieldsValue(true) as { name: string }
const values = await form.validateFields()
if (!values.name) {
MessagePlugin.warning('请输入姓名')
messageApi.warning('请输入姓名')
return
}
const name = values.name.trim()
if (data.some((s) => s.name === name)) {
MessagePlugin.warning('学生姓名已存在')
messageApi.warning('学生姓名已存在')
return
}
const res = await (window as any).api.createStudent({ ...values, name })
if (res.success) {
MessagePlugin.success('添加成功')
messageApi.success('添加成功')
setVisible(false)
form.reset()
form.resetFields()
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '添加失败')
messageApi.error(res.message || '添加失败')
}
} catch (err) {
try {
@@ -155,22 +149,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleDelete = async (id: number) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const res = await (window as any).api.deleteStudent(id)
if (res.success) {
MessagePlugin.success('删除成功')
messageApi.success('删除成功')
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '删除失败')
messageApi.error(res.message || '删除失败')
}
}
const handleOpenTagEditor = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setEditingStudent(student)
@@ -183,18 +177,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds)
if (res && res.success) {
MessagePlugin.success('标签保存成功')
messageApi.success('标签保存成功')
setTagEditVisible(false)
setEditingStudent(null)
fetchStudents()
emitDataUpdated('students')
} else {
const errorMsg = res?.message || '保存标签失败'
MessagePlugin.error(errorMsg)
messageApi.error(errorMsg)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
MessagePlugin.error(`保存标签失败: ${errorMsg}`)
messageApi.error(`保存标签失败: ${errorMsg}`)
}
}
@@ -211,7 +205,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const parseXlsxFile = async (file: File) => {
if (!xlsxWorkerRef.current) {
MessagePlugin.error('Worker 未初始化')
messageApi.error('Worker 未初始化')
return
}
@@ -219,13 +213,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const buf = await file.arrayBuffer()
// 使用 Worker 处理文件解析,避免阻塞主线程
xlsxWorkerRef.current.postMessage({
type: 'parseXlsx',
data: { buffer: buf }
})
// 监听 Worker 消息
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'success') {
setXlsxFileName(file.name)
@@ -235,7 +227,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setImportVisible(false)
setXlsxLoading(false)
} else if (event.data.type === 'error') {
MessagePlugin.error(event.data.error || '解析 xlsx 失败')
messageApi.error(event.data.error || '解析 xlsx 失败')
setXlsxLoading(false)
}
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
@@ -243,7 +235,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
xlsxWorkerRef.current.addEventListener('message', handleMessage)
} catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败')
messageApi.error(e?.message || '解析 xlsx 失败')
setXlsxLoading(false)
}
}
@@ -269,26 +261,34 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}, [xlsxAoa, xlsxMaxCols])
const xlsxPreviewColumns = useMemo(() => {
const cols: PrimaryTableCol<any>[] = [
{ colKey: '__row', title: '#', width: 60, align: 'center', fixed: 'left' as any }
const cols: any[] = [
{
title: '#',
dataIndex: '__row',
key: '__row',
width: 60,
align: 'center' as const,
fixed: 'left' as const
}
]
for (let c = 0; c < xlsxMaxCols; c++) {
const selected = xlsxSelectedCol === c
cols.push({
colKey: `c${c}`,
title: (
<span
style={{
cursor: 'pointer',
fontWeight: selected ? 700 : 500,
color: selected ? 'var(--td-brand-color)' : undefined
color: selected ? 'var(--ant-color-primary, #1890ff)' : undefined
}}
onClick={() => setXlsxSelectedCol(c)}
>
{excelColName(c)}
</span>
),
minWidth: 120
dataIndex: `c${c}`,
key: `c${c}`,
width: 120
})
}
return cols
@@ -313,17 +313,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleConfirmXlsxImport = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
if (xlsxSelectedCol == null) {
MessagePlugin.warning('请先点击选择姓名列')
messageApi.warning('请先点击选择"姓名列"')
return
}
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
if (!names.length) {
MessagePlugin.error('所选列未解析到可导入的姓名')
messageApi.error('所选列未解析到可导入的姓名')
return
}
@@ -331,12 +331,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const res = await (window as any).api.importStudentsFromXlsx({ names })
if (!res?.success) {
MessagePlugin.error(res?.message || '导入失败')
messageApi.error(res?.message || '导入失败')
return
}
const inserted = Number(res?.data?.inserted ?? 0)
const skipped = Number(res?.data?.skipped ?? 0)
MessagePlugin.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
messageApi.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
setXlsxVisible(false)
setXlsxAoa([])
setXlsxFileName('')
@@ -348,75 +348,60 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
const columns: PrimaryTableCol<student>[] = [
{ colKey: 'name', title: '姓名', width: 100 },
const columns: ColumnsType<student> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{
colKey: 'score',
title: '当前积分',
dataIndex: 'score',
key: 'score',
width: 160,
align: 'center',
cell: ({ row }) => (
render: (score: number) => (
<span
style={{
fontWeight: 'bold',
color:
row.score > 0
? 'var(--td-success-color)'
: row.score < 0
? 'var(--td-error-color)'
score > 0
? 'var(--ant-color-success, #52c41a)'
: score < 0
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit'
}}
>
{row.score > 0 ? `+${row.score}` : row.score}
{score > 0 ? `+${score}` : score}
</span>
)
},
{
colKey: 'tags',
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 200,
cell: ({ row }) => {
const tags = row.tags || []
return (
render: (tags: string[] = []) => (
<Space>
{tags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
tags.slice(0, 3).map((tag) => (
<Tag key={tag} theme="primary" size="small">
<Tag key={tag} color="blue">
{tag}
</Tag>
))
)}
{tags.length > 3 && (
<Tag theme="default" size="small">
+{tags.length - 3}
</Tag>
)}
{tags.length > 3 && <Tag>+{tags.length - 3}</Tag>}
</Space>
)
}
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
render: (_, row) => (
<Space>
<Button
theme="default"
variant="text"
disabled={!canEdit}
onClick={() => handleOpenTagEditor(row)}
>
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
</Button>
<Button
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row.id)}
>
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
</Button>
</Space>
@@ -424,59 +409,69 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
]
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize)
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Space>
<Button variant="outline" disabled={!canEdit} onClick={() => setImportVisible(true)}>
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
</Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</Space>
</div>
<Table
data={data.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
dataSource={paginatedData}
columns={columns}
rowKey="id"
loading={loading}
hover
pagination={{
current: currentPage,
pageSize,
total: data.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
pagination={false}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={data.length}
onChange={(page, size) => {
setCurrentPage(page)
setPageSize(size)
}}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div>
{/* 添加学生弹窗 */}
<Dialog
header="添加学生"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
<Modal
title="添加学生"
open={visible}
onOk={handleAdd}
onCancel={() => setVisible(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="姓名" name="name">
<Form form={form} layout="vertical">
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入学生姓名" />
</Form.FormItem>
</Form.Item>
</Form>
</Dialog>
</Modal>
<Dialog
header="导入名单"
visible={importVisible}
onClose={() => setImportVisible(false)}
footer={false}
destroyOnClose
<Modal
title="导入名单"
open={importVisible}
onCancel={() => setImportVisible(false)}
footer={null}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
<Button
loading={xlsxLoading}
disabled={!canEdit}
@@ -498,16 +493,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}}
/>
</Space>
</Dialog>
</Modal>
<Dialog
header="xlsx 预览与导入"
visible={xlsxVisible}
onClose={() => setXlsxVisible(false)}
confirmBtn={{ content: '导入', loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
onConfirm={handleConfirmXlsxImport}
<Modal
title="xlsx 预览与导入"
open={xlsxVisible}
onCancel={() => setXlsxVisible(false)}
onOk={handleConfirmXlsxImport}
okText="导入"
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
width="80%"
destroyOnClose
destroyOnHidden
>
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
<div>{xlsxFileName || '-'}</div>
@@ -517,17 +513,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div> 50 </div>
</div>
<Table
data={xlsxPreviewRows}
dataSource={xlsxPreviewRows}
columns={xlsxPreviewColumns}
rowKey="__row"
bordered
hover
maxHeight={420}
scroll={{ y: 420 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
pagination={false}
/>
</Dialog>
</Modal>
{/* 标签编辑弹窗 */}
<TagEditorDialog
visible={tagEditVisible}
onClose={() => {
+31 -45
View File
@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Dialog, Input, Button, Space, Tag, MessagePlugin } from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext' // 导入主题上下文
import { Modal, Input, Button, Space, Tag, message } from 'antd'
interface Tag {
interface TagItem {
id: number
name: string
}
@@ -23,13 +22,10 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
title = '编辑标签'
}) => {
const [inputValue, setInputValue] = useState('')
const [allTags, setAllTags] = useState<Tag[]>([])
const [allTags, setAllTags] = useState<TagItem[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
const { currentTheme } = useTheme() // 获取当前主题
const [messageApi, contextHolder] = message.useMessage()
const themeMode = currentTheme?.mode || 'light' // 默认为 light
// fetchAllTags is declared as a function so it can be called from useEffect
async function fetchAllTags() {
if (!(window as any).api) return
try {
@@ -39,7 +35,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
}
} catch (e) {
console.error('Failed to fetch tags:', e)
MessagePlugin.error('获取标签列表失败')
messageApi.error('获取标签列表失败')
}
}
@@ -56,7 +52,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
if (!trimmed) return
if (trimmed.length > 30) {
MessagePlugin.error('标签名称不能超过 30 个字符')
messageApi.error('标签名称不能超过 30 个字符')
return
}
@@ -76,11 +72,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
setInputValue('')
} else {
MessagePlugin.error(res.message || '添加标签失败')
messageApi.error(res.message || '添加标签失败')
}
} catch (e) {
console.error('Failed to create tag:', e)
MessagePlugin.error('添加标签失败')
messageApi.error('添加标签失败')
}
}
@@ -106,23 +102,16 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
newSet.delete(tagId)
return newSet
})
MessagePlugin.success('标签删除成功')
messageApi.success('标签删除成功')
} else {
MessagePlugin.error(res.message || '删除标签失败')
messageApi.error(res.message || '删除标签失败')
}
} catch (e) {
console.error('Failed to delete tag:', e)
MessagePlugin.error('删除标签失败')
messageApi.error('删除标签失败')
}
}
/* const handleKeyDown = (value: string, context: { e: React.KeyboardEvent<HTMLInputElement> }) => {
if (context.e.key === 'Enter') {
context.e.preventDefault()
handleAddTag()
}
} */
const handleConfirm = () => {
onConfirm(Array.from(selectedTagIds))
onClose()
@@ -132,33 +121,31 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
const availableTags = allTags.filter((t) => !selectedTagIds.has(t.id))
return (
<Dialog
header={title}
visible={visible}
onClose={onClose}
onConfirm={handleConfirm}
confirmBtn={{ content: '保存', theme: 'primary' }}
cancelBtn={{ content: '取消' }}
destroyOnClose
<Modal
title={title}
open={visible}
onCancel={onClose}
onOk={handleConfirm}
okText="保存"
cancelText="取消"
destroyOnHidden
width={500}
>
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 标签输入区 */}
<div style={{ display: 'flex', gap: '8px' }}>
<Input
placeholder="输入标签名称,按 Enter 添加"
value={inputValue}
onChange={setInputValue}
onEnter={handleAddTag}
/* maxLength={50} */
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleAddTag}
style={{ flex: 1 }}
/>
<Button theme="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
</Button>
</div>
{/* 已选标签区 */}
<div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -174,12 +161,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{selectedTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
<Space>
<Space wrap>
{selectedTags.map((tag) => (
<Tag
key={tag.id}
theme="primary"
variant={themeMode === 'dark' ? 'outline' : 'light'} // 根据主题模式动态设置变体
color="blue"
closable
onClose={() => handleToggleTag(tag.id)}
style={{ cursor: 'pointer' }}
@@ -192,7 +178,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div>
</div>
{/* 可选标签区 */}
<div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -208,14 +193,15 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{availableTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
<Space>
<Space wrap>
{availableTags.map((tag) => (
<Tag
key={tag.id}
theme="default"
variant={themeMode === 'dark' ? 'light' : 'outline'} // 根据主题模式动态设置变体
closable
onClose={() => handleDeleteTag(tag.id)}
onClose={(e) => {
e.preventDefault()
handleDeleteTag(tag.id)
}}
style={{ cursor: 'pointer' }}
onClick={() => handleToggleTag(tag.id)}
>
@@ -227,6 +213,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div>
</div>
</div>
</Dialog>
</Modal>
)
}
+33 -51
View File
@@ -1,16 +1,6 @@
import React, { useEffect } from 'react'
import {
Drawer,
Form,
Input,
Select,
ColorPicker,
Button,
Space,
Divider,
Row,
Col
} from 'tdesign-react'
import { Drawer, Form, Input, Select, Button, Space, Divider, Row, Col, ColorPicker } from 'antd'
import type { Color } from 'antd/es/color-picker'
import { useThemeEditor } from '../contexts/ThemeEditorContext'
import { useTheme } from '../contexts/ThemeContext'
import { generateColorMap } from '../utils/color'
@@ -59,7 +49,6 @@ export const ThemeEditor: React.FC = () => {
const { currentTheme } = useTheme()
// 实时预览逻辑
useEffect(() => {
if (!isEditing || !editingTheme) return
@@ -67,10 +56,8 @@ export const ThemeEditor: React.FC = () => {
const { tdesign, custom } = theme.config
const root = document.documentElement
// 1. 设置 TDesign 模式
root.setAttribute('theme-mode', theme.mode)
// 2. 设置 TDesign 品牌色
if (tdesign.brandColor) {
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
Object.entries(colorMap).forEach(([key, value]) => {
@@ -78,7 +65,6 @@ export const ThemeEditor: React.FC = () => {
})
}
// 3. 应用自定义变量
Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value)
})
@@ -87,7 +73,6 @@ export const ThemeEditor: React.FC = () => {
applyPreview(editingTheme)
}, [editingTheme, isEditing])
// 关闭时恢复原有主题
useEffect(() => {
if (!isEditing && currentTheme) {
const { tdesign, custom } = currentTheme.config
@@ -112,39 +97,36 @@ export const ThemeEditor: React.FC = () => {
return (
<Drawer
header="编辑主题"
visible={isEditing}
title="编辑主题"
open={isEditing}
onClose={cancelEditing}
size="500px"
width={500}
footer={
<Space>
<Button theme="primary" onClick={saveEditingTheme}>
<Button type="primary" onClick={saveEditingTheme}>
</Button>
<Button theme="default" onClick={cancelEditing}>
</Button>
<Button onClick={cancelEditing}></Button>
</Space>
}
destroyOnClose
destroyOnHidden
>
<Form labelAlign="top">
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 基本信息 */}
<Form layout="vertical">
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
<Divider align="left"></Divider>
<Divider></Divider>
<Row gutter={[16, 16]}>
<Col span={12}>
<Form.FormItem label="主题名称">
<Form.Item label="主题名称">
<Input
value={editingTheme.name}
onChange={(v) => updateEditingTheme({ name: v })}
onChange={(e) => updateEditingTheme({ name: e.target.value })}
placeholder="请输入主题名称"
/>
</Form.FormItem>
</Form.Item>
</Col>
<Col span={12}>
<Form.FormItem label="色彩模式">
<Form.Item label="色彩模式">
<Select
value={editingTheme.mode}
onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })}
@@ -153,36 +135,35 @@ export const ThemeEditor: React.FC = () => {
{ label: '深色 (Dark)', value: 'dark' }
]}
/>
</Form.FormItem>
</Form.Item>
</Col>
<Col span={24}>
<Form.FormItem label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
<Form.Item label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
<Input
value={editingTheme.id}
onChange={(v) => updateEditingTheme({ id: v })}
onChange={(e) => updateEditingTheme({ id: e.target.value })}
placeholder="请输入主题 ID"
/>
</Form.FormItem>
</Form.Item>
</Col>
</Row>
</div>
{/* TDesign 品牌色 */}
<div>
<Divider align="left"> (Brand)</Divider>
<Form.FormItem label="主品牌色" help="将自动生成一系列色阶">
<Divider> (Brand)</Divider>
<Form.Item label="主品牌色" help="将自动生成一系列色阶">
<ColorPicker
value={editingTheme.config.tdesign.brandColor}
onChange={(v) => updateConfig('tdesign', 'brandColor', v)}
enableAlpha={false}
format="HEX"
onChange={(color: Color) =>
updateConfig('tdesign', 'brandColor', color.toHexString())
}
showText
/>
</Form.FormItem>
</Form.Item>
</div>
{/* 业务自定义变量 */}
<div>
<Divider align="left"> (Custom)</Divider>
<Divider> (Custom)</Divider>
{Object.entries(variableGroups).map(([groupKey, group]) => (
<div key={groupKey} style={{ marginBottom: 24 }}>
<div
@@ -190,7 +171,7 @@ export const ThemeEditor: React.FC = () => {
fontSize: '13px',
fontWeight: 600,
marginBottom: '12px',
color: 'var(--td-text-color-primary)'
color: 'var(--ss-text-main)'
}}
>
{group.title}
@@ -202,7 +183,7 @@ export const ThemeEditor: React.FC = () => {
<div
style={{
fontSize: '12px',
color: 'var(--td-text-color-secondary)',
color: 'var(--ss-text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
@@ -213,9 +194,10 @@ export const ThemeEditor: React.FC = () => {
</div>
<ColorPicker
value={editingTheme.config.custom[item.key] || '#ffffff'}
onChange={(v) => updateConfig('custom', item.key, v)}
enableAlpha
format="HEX"
onChange={(color: Color) =>
updateConfig('custom', item.key, color.toHexString())
}
showText
/>
</div>
</Col>
+15 -14
View File
@@ -1,5 +1,10 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { Button } from 'antd'
import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react'
import electronLogo from '../assets/electron.svg'
@@ -11,10 +16,9 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
if (!(window as any).api) return
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
@@ -96,33 +100,30 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
}
>
<Button
variant="text"
shape="square"
type="text"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
<MinusOutlined />
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? (
<FullscreenExitIcon style={{ transform: 'scale(0.5)' }} />
<FullscreenExitOutlined style={{ transform: 'scale(0.8)' }} />
) : (
<RectangleIcon />
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
)}
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
<CloseOutlined />
</Button>
</div>
+15 -14
View File
@@ -1,15 +1,19 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { Button } from 'antd'
import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react'
export function WindowControls(): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
if (!(window as any).api) return
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
@@ -43,33 +47,30 @@ export function WindowControls(): React.JSX.Element {
}
>
<Button
variant="text"
shape="square"
type="text"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
<MinusOutlined />
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? (
<FullscreenExitIcon />
<FullscreenExitOutlined />
) : (
<RectangleIcon style={{ transform: 'scale(0.7)' }} />
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
)}
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
<CloseOutlined />
</Button>
<style>
{`
+25 -20
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Dialog, Form, Select, MessagePlugin, Typography } from 'tdesign-react'
import { Modal, Form, Select, message, Typography } from 'antd'
import { useTheme } from '../contexts/ThemeContext'
interface wizardProps {
@@ -10,6 +10,7 @@ interface wizardProps {
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme()
const [loading, setLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const handleFinish = async () => {
setLoading(true)
@@ -18,39 +19,43 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const res = await (window as any).api.setSetting('is_wizard_completed', true)
if (!res?.success) throw new Error(res?.message || 'failed')
MessagePlugin.success('配置完成!')
messageApi.success('配置完成!')
onComplete()
} catch {
MessagePlugin.error('配置保存失败')
messageApi.error('配置保存失败')
} finally {
setLoading(false)
}
}
return (
<Dialog
header="欢迎使用 SecScore 积分管理"
visible={visible}
confirmBtn={{ content: '开启积分之旅', loading }}
cancelBtn={null}
closeOnEscKeydown={false}
closeOnOverlayClick={false}
onConfirm={handleFinish}
<Modal
title="欢迎使用 SecScore 积分管理"
open={visible}
onOk={handleFinish}
onCancel={() => {}}
confirmLoading={loading}
okText="开启积分之旅"
cancelButtonProps={{ style: { display: 'none' } }}
closable={false}
mask={{ closable: false }}
keyboard={false}
width={500}
>
{contextHolder}
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
SecScore
</Typography.Paragraph>
<Form labelWidth={100}>
<Form.FormItem label="外观主题">
<Select value={currentTheme?.id} onChange={(v) => setTheme(v as string)}>
{themes.map((t) => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
</Form.FormItem>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label="外观主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
options={themes.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</Form>
</Dialog>
</Modal>
)
}
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react'
import { Delete1Icon } from 'tdesign-icons-react'
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { actionRegistry, allActions } from './registry'
import type { ActionItem as ActionItemType } from './types'
@@ -23,15 +23,10 @@ const ActionItem: React.FC<ActionItemProps> = ({
return (
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px', marginRight: 12 }}
style={{ width: '200px' }}
options={allActions.options}
placeholder="请选择触发行动"
onChange={(value) => onChange(item.id, value as string)}
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react'
import { Delete1Icon } from 'tdesign-icons-react'
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { triggerRegistry, allTriggers } from './registry'
import type { TriggerItem as TriggerItemType } from './types'
@@ -25,25 +25,20 @@ const TriggerItem: React.FC<TriggerItemProps> = ({
const relation = item.relation || 'AND'
return (
<div style={{ display: 'flex', gap: 5 }}>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
{!isFirst && (
<Button
theme={relation === 'AND' ? 'primary' : 'warning'}
variant={relation === 'AND' ? 'base' : 'outline'}
type={relation === 'AND' ? 'primary' : 'default'}
danger={relation === 'OR'}
onClick={() => onRelationChange?.(item.id, relation === 'AND' ? 'OR' : 'AND')}
>
{relation === 'AND' ? '并' : '或'}
</Button>
)}
<Button
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px', marginRight: 12 }}
style={{ width: '200px' }}
options={allTriggers.options}
placeholder="请选择触发规则"
onChange={(value) => onChange(item.id, value as string)}
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_score'
@@ -18,13 +18,13 @@ const AddScoreAction: React.FC<ActionComponentProps> = ({
placeholder="请输入分数"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
<Input
placeholder="请输入理由"
style={{ width: '150px' }}
value={reason ?? ''}
onChange={(v: any) => onReasonChange?.(v ? String(v) : '')}
onChange={(e) => onReasonChange?.(e.target.value ? String(e.target.value) : '')}
/>
</>
)
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_tag'
@@ -12,7 +12,7 @@ const AddTagAction: React.FC<ActionComponentProps> = ({ value, onChange }) => {
placeholder="请输入标签"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'send_notification'
@@ -12,7 +12,7 @@ const SendNotificationAction: React.FC<ActionComponentProps> = ({ value, onChang
placeholder="请输入通知内容"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { InputNumber, Space, Radio, Form } from 'tdesign-react'
import { InputNumber, Space, Radio } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'interval_time_passed'
@@ -22,8 +22,8 @@ const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange
const numValue = value ? parseInt(value, 10) : undefined
const [unit, setUnit] = useState<'minutes' | 'days'>('minutes')
const handleChange = (v: any) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined
const handleChange = (v: number | null) => {
const numV = v
if (numV === undefined || numV === null || isNaN(numV)) {
onChange('')
return
@@ -41,37 +41,22 @@ const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange
return (
<Space>
<Form.FormItem
name="intervalMinutes"
rules={[
{ required: true, message: '请输入时间' },
{ min: 1, message: unit === 'minutes' ? '间隔时间至少为1分钟' : '间隔时间至少为1天' }
]}
style={{ marginBottom: 0 }}
>
<InputNumber
placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'}
style={{ width: '100px' }}
value={displayValue}
onChange={handleChange}
min={1}
theme="column"
/>
</Form.FormItem>
<Form.FormItem
name="timeUnit"
initialData="minutes"
style={{ marginBottom: 0, marginLeft: -12 }}
>
<Radio.Group
variant="default-filled"
value={unit}
onChange={(v) => setUnit(String(v) as 'minutes' | 'days')}
onChange={(e) => setUnit(e.target.value as 'minutes' | 'days')}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="days"></Radio.Button>
<Radio.Button value="minutes"></Radio.Button>
</Radio.Group>
</Form.FormItem>
</Space>
)
}
@@ -1,4 +1,4 @@
import { InputNumber, Row, Col } from 'tdesign-react'
import { InputNumber, Space } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'random_time_reached'
@@ -33,15 +33,14 @@ const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
console.debug('RandomTimeTrigger parse error', e)
}
const handleChange = (key: keyof RandomTimeConfig, v: any) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined
const newConfig = { ...config, [key]: numV ?? (key === 'minHour' ? 0 : 23) }
const handleChange = (key: keyof RandomTimeConfig, v: number | null) => {
const numV = v ?? (key === 'minHour' ? 0 : 23)
const newConfig = { ...config, [key]: numV }
onChange(JSON.stringify(newConfig))
}
return (
<Row gutter={8}>
<Col>
<Space>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
@@ -51,12 +50,9 @@ const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
onChange={(v) => handleChange('minHour', v)}
min={0}
max={23}
theme="column"
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Col>
<Col>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
@@ -66,12 +62,10 @@ const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
onChange={(v) => handleChange('maxHour', v)}
min={0}
max={23}
theme="column"
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Col>
</Row>
</Space>
)
}
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Select } from 'tdesign-react'
import { Select } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'student_tag_matched'
export const label = '当学生匹配标签时触发'
export const description = '当学生匹配特定标签时触发自动化'
@@ -37,11 +38,11 @@ const StudentTagTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
<Select
placeholder="请选择标签"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
value={value ?? undefined}
onChange={(v) => onChange(v ? String(v) : '')}
options={tags}
filterable
clearable
showSearch
allowClear
/>
)
}

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