请输入文本

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
+3 -2
View File
@@ -13,8 +13,7 @@
<img alt="Vite" src="https://img.shields.io/badge/Vite-7+-646CFF?style=flat-square&logo=vite"> <img alt="Vite" src="https://img.shields.io/badge/Vite-7+-646CFF?style=flat-square&logo=vite">
</p> </p>
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。 SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。
## 主要功能 ## 主要功能
@@ -73,9 +72,11 @@ SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScri
4. 点击“确认提交” 4. 点击“确认提交”
快捷理由: 快捷理由:
- 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值) - 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)
撤销最近记录: 撤销最近记录:
- “最近记录”默认折叠,展开后可对记录点击“撤销” - “最近记录”默认折叠,展开后可对记录点击“撤销”
- 撤销会回滚该条记录对学生积分的影响 - 撤销会回滚该条记录对学生积分的影响
-1
View File
@@ -227,4 +227,3 @@ start "" "secscore://leaderboard"
``` ```
此类扩展只需在主进程的 URL 解析函数中增加相应分支逻辑即可。 此类扩展只需在主进程的 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:unpack": "pnpm -s build && electron-builder --dir --publish never",
"build:win": "pnpm -s build && electron-builder --win --publish never", "build:win": "pnpm -s build && electron-builder --win --publish never",
"build:mac": "electron-vite build && electron-builder --mac --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": { "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/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"antd": "^6.3.1",
"antd-mobile": "^5.42.3",
"better-sqlite3": "^12.6.0", "better-sqlite3": "^12.6.0",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"es-object-atoms": "^1.1.1", "es-object-atoms": "^1.1.1",
"express": "^5.2.1", "express": "^5.2.1",
"mica-electron": "^1.5.16", "mica-electron": "^1.5.16",
"os": "^0.1.2", "os": "^0.1.2",
"pg": "^8.19.0",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"tdesign-react": "^1.16.3",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -46,6 +58,7 @@
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
+1660 -150
View File
File diff suppressed because it is too large Load Diff
+218 -31
View File
@@ -17,42 +17,229 @@ declare module '../../shared/kernel' {
} }
} }
export class DbManager extends Service { interface PostgreSQLConfig {
public readonly dataSource: DataSource host: string
port: number
username: string
password: string
database: string
ssl?: boolean
sslmode?: string
channelBinding?: string
}
constructor(ctx: Context, dbPath: string) { interface QueueItem {
super(ctx, 'db') id: string
const dbDir = path.dirname(dbPath) operation: () => Promise<any>
if (!fs.existsSync(dbDir)) { resolve: (value: any) => void
fs.mkdirSync(dbDir, { recursive: true }) reject: (error: Error) => void
} timestamp: number
this.dataSource = new DataSource({ }
type: 'better-sqlite3',
database: dbPath, export function parsePostgreSQLConnectionString(connectionString: string): PostgreSQLConfig | null {
entities: [ if (!connectionString.startsWith('postgresql://')) {
StudentEntity, return null
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false
})
} }
async initialize() { try {
if (this.dataSource.isInitialized) return const url = new URL(connectionString)
await this.dataSource.initialize() const config: PostgreSQLConfig = {
await this.dataSource.query('PRAGMA foreign_keys = ON') host: url.hostname,
await this.dataSource.runMigrations() port: url.port ? parseInt(url.port, 10) : 5432,
username: url.username,
password: decodeURIComponent(url.password || ''),
database: url.pathname.slice(1),
ssl: false
}
const params = new URLSearchParams(url.search)
if (params.get('sslmode')) {
config.ssl = true
config.sslmode = params.get('sslmode') || 'require'
}
if (params.get('channel_binding')) {
config.channelBinding = params.get('channel_binding') || 'require'
}
return config
} catch (e) {
console.error('Failed to parse PostgreSQL connection string:', e)
return null
}
}
export class DbManager extends Service {
private _dataSource: DataSource | null = null
private _isPostgreSQL: boolean = false
private dbPath: string
private operationQueue: QueueItem[] = []
private isProcessingQueue: boolean = false
private queueIdCounter: number = 0
constructor(ctx: Context, dbPath: string, pgConnectionString?: string) {
super(ctx, 'db')
this.dbPath = dbPath
this._isPostgreSQL = !!pgConnectionString
}
get dataSource(): DataSource {
if (!this._dataSource) {
throw new Error('Database not initialized. Call initialize() first.')
}
return this._dataSource
}
get isPostgreSQL(): boolean {
return this._isPostgreSQL
}
private createDataSource(pgConnectionString?: string): DataSource {
const pgConfig = pgConnectionString ? parsePostgreSQLConnectionString(pgConnectionString) : null
if (pgConfig) {
this._isPostgreSQL = true
return new DataSource({
type: 'postgres',
host: pgConfig.host,
port: pgConfig.port,
username: pgConfig.username,
password: pgConfig.password,
database: pgConfig.database,
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : false,
extra: {
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : undefined,
sslmode: pgConfig.sslmode,
channelBinding: pgConfig.channelBinding
},
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false,
poolSize: 10,
connectTimeoutMS: 30000
})
} else {
this._isPostgreSQL = false
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
return new DataSource({
type: 'better-sqlite3',
database: this.dbPath,
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false
})
}
}
async initialize(pgConnectionString?: string) {
if (this._dataSource?.isInitialized) {
await this.dispose()
}
this._dataSource = this.createDataSource(pgConnectionString)
await this._dataSource.initialize()
if (!this._isPostgreSQL) {
await this._dataSource.query('PRAGMA foreign_keys = ON')
}
await this._dataSource.runMigrations()
} }
async dispose() { async dispose() {
if (!this.dataSource.isInitialized) return if (!this._dataSource?.isInitialized) return
await this.dataSource.destroy() 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 { export class InitSchema2026011800000 implements MigrationInterface {
name = 'InitSchema2026011800000' name = 'InitSchema2026011800000'
private isPostgres(queryRunner: QueryRunner): boolean {
return queryRunner.connection.options.type === 'postgres'
}
async up(queryRunner: QueryRunner): Promise<void> { 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'))) { if (!(await queryRunner.hasTable('students'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "students" ( CREATE TABLE "students" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"name" text NOT NULL, "name" text NOT NULL,
"score" integer NOT NULL DEFAULT (0), "score" integer NOT NULL ${defaultZero},
"extra_json" text, "extra_json" text,
"tags" text NOT NULL DEFAULT '[]', "tags" text NOT NULL ${defaultEmptyArr},
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP), "created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP) "updated_at" text NOT NULL DEFAULT ${now}
) )
`) `)
} }
@@ -21,12 +31,12 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('reasons'))) { if (!(await queryRunner.hasTable('reasons'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "reasons" ( CREATE TABLE "reasons" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"content" text NOT NULL, "content" text NOT NULL,
"category" text NOT NULL DEFAULT ('其他'), "category" text NOT NULL DEFAULT '其他',
"delta" integer NOT NULL, "delta" integer NOT NULL,
"is_system" integer NOT NULL DEFAULT (0), "is_system" integer NOT NULL ${defaultZero},
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updated_at" text NOT NULL DEFAULT ${now},
CONSTRAINT "UQ_reasons_content" UNIQUE ("content") CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
) )
`) `)
@@ -35,10 +45,10 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('settlements'))) { if (!(await queryRunner.hasTable('settlements'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "settlements" ( CREATE TABLE "settlements" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"start_time" text NOT NULL, "start_time" text NOT NULL,
"end_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'))) { if (!(await queryRunner.hasTable('score_events'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "score_events" ( CREATE TABLE "score_events" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"uuid" text NOT NULL, "uuid" text NOT NULL,
"student_name" text NOT NULL, "student_name" text NOT NULL,
"reason_content" text NOT NULL, "reason_content" text NOT NULL,
"delta" integer NOT NULL, "delta" integer NOT NULL,
"val_prev" integer NOT NULL, "val_prev" integer NOT NULL,
"val_curr" 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, "settlement_id" integer,
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid") CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
) )
@@ -72,23 +82,28 @@ export class InitSchema2026011800000 implements MigrationInterface {
`) `)
} }
await queryRunner.query(` 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)`
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP), if (isPg) {
('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP), await queryRunner.query(`
('纪律良好','纪律',1,1,CURRENT_TIMESTAMP), INSERT INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
('帮助同学','品德',2,1,CURRENT_TIMESTAMP), ${reasonValues}
('迟到','纪律',-1,1,CURRENT_TIMESTAMP), ON CONFLICT DO NOTHING
('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP) `)
`) } else {
await queryRunner.query(`
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
${reasonValues}
`)
}
if (!(await queryRunner.hasTable('tags'))) { if (!(await queryRunner.hasTable('tags'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "tags" ( CREATE TABLE "tags" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"name" text NOT NULL UNIQUE, "name" text NOT NULL UNIQUE,
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP), "created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP) "updated_at" text NOT NULL DEFAULT ${now}
) )
`) `)
} }
@@ -96,10 +111,10 @@ export class InitSchema2026011800000 implements MigrationInterface {
if (!(await queryRunner.hasTable('student_tags'))) { if (!(await queryRunner.hasTable('student_tags'))) {
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "student_tags" ( CREATE TABLE "student_tags" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" ${pkSerial} NOT NULL,
"student_id" integer NOT NULL, "student_id" integer NOT NULL,
"tag_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 ("student_id") REFERENCES "students"("id") ON DELETE CASCADE,
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE,
UNIQUE("student_id", "tag_id") 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")` `CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
) )
await queryRunner.query(` const tagValues = `('优秀生'),('班干部'),('勤奋'),('活跃'),('进步快')`
INSERT OR IGNORE INTO "tags" ("name") VALUES
('优秀生'), if (isPg) {
('班干部'), await queryRunner.query(`
('勤奋'), INSERT INTO "tags" ("name") VALUES ${tagValues}
('活跃'), ON CONFLICT DO NOTHING
('进步快') `)
`) } else {
await queryRunner.query(`
INSERT OR IGNORE INTO "tags" ("name") VALUES ${tagValues}
`)
}
} }
async down(queryRunner: QueryRunner): Promise<void> { 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 { TrayService } from './services/TrayService'
import { AutoScoreService } from './services/AutoScoreService' import { AutoScoreService } from './services/AutoScoreService'
import { FileSystemService } from './services/FileSystemService' import { FileSystemService } from './services/FileSystemService'
import { DbConnectionService } from './services/DbConnectionService'
import { StudentRepository } from './repos/StudentRepository' import { StudentRepository } from './repos/StudentRepository'
import { ReasonRepository } from './repos/ReasonRepository' import { ReasonRepository } from './repos/ReasonRepository'
import { EventRepository } from './repos/EventRepository' import { EventRepository } from './repos/EventRepository'
@@ -50,6 +51,7 @@ type mainAppConfig = {
logDir: string logDir: string
themeDir: string themeDir: string
dbPath: string dbPath: string
pgConnectionString?: string
window: windowManagerOptions window: windowManagerOptions
} }
@@ -191,6 +193,8 @@ app.whenReady().then(async () => {
: ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes')) : ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes'))
const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite') const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite')
const pgConnectionString = process.env['PG_CONNECTION_STRING'] || undefined
const config: mainAppConfig = { const config: mainAppConfig = {
isDev: is.dev, isDev: is.dev,
appRoot, appRoot,
@@ -199,6 +203,7 @@ app.whenReady().then(async () => {
configDir, configDir,
themeDir, themeDir,
dbPath, dbPath,
pgConnectionString,
window: { window: {
icon, icon,
preloadPath: join(__dirname, '../preload/index.js'), preloadPath: join(__dirname, '../preload/index.js'),
@@ -227,7 +232,10 @@ app.whenReady().then(async () => {
LoggerToken, LoggerToken,
(p) => new LoggerService(p.get(MainContext), config.logDir) (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(SettingsStoreToken, (p) => new SettingsService(p.get(MainContext)))
services.addSingleton(SecurityServiceToken, (p) => new SecurityService(p.get(MainContext))) services.addSingleton(SecurityServiceToken, (p) => new SecurityService(p.get(MainContext)))
services.addSingleton( services.addSingleton(
@@ -272,14 +280,37 @@ app.whenReady().then(async () => {
(p) => new FileSystemService(p.get(MainContext), config.configDir) (p) => new FileSystemService(p.get(MainContext), config.configDir)
) )
services.addSingleton(AutoScoreServiceToken, (p) => new AutoScoreService(p.get(MainContext))) services.addSingleton(AutoScoreServiceToken, (p) => new AutoScoreService(p.get(MainContext)))
services.addSingleton(DbConnectionService, (p) => new DbConnectionService(p.get(MainContext)))
}) })
.configure(async (_builderContext, appCtx) => { .configure(async (_builderContext, appCtx) => {
const services = appCtx.services const services = appCtx.services
services.get(LoggerToken) services.get(LoggerToken)
// 先初始化 db(使用默认 SQLite)
const db = services.get(DbManagerToken) as DbManager const db = services.get(DbManagerToken) as DbManager
await db.initialize() await db.initialize()
// 然后初始化 settings
const settings = services.get(SettingsStoreToken) as SettingsService const settings = services.get(SettingsStoreToken) as SettingsService
await settings.initialize() 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(SecurityServiceToken)
services.get(PermissionServiceToken) services.get(PermissionServiceToken)
services.get(AuthService) services.get(AuthService)
@@ -300,32 +331,7 @@ app.whenReady().then(async () => {
services.get(FileSystemServiceToken) services.get(FileSystemServiceToken)
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
await autoScore.initialize?.() await autoScore.initialize?.()
}) services.get(DbConnectionService)
.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)
}) })
const host = await builder.build() const host = await builder.build()
+15 -7
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) { async queryByStudent(studentName: string, startTime: string | null, limit: number) {
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity) const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
const qb = repo const qb = repo
@@ -170,7 +174,11 @@ export class EventRepository extends Service {
.orderBy('e.event_time', 'DESC') .orderBy('e.event_time', 'DESC')
.limit(limit) .limit(limit)
if (startTime) { if (startTime) {
qb.andWhere('julianday(e.event_time) >= julianday(:startTime)', { 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() return await qb.getMany()
} }
@@ -193,15 +201,15 @@ export class EventRepository extends Service {
} }
const startTime = start.toISOString() 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 const qb = this.ctx.db.dataSource
.getRepository(StudentEntity) .getRepository(StudentEntity)
.createQueryBuilder('s') .createQueryBuilder('s')
.leftJoin( .leftJoin(ScoreEventEntity, 'e', joinCondition, { startTime })
ScoreEventEntity,
'e',
'e.student_name = s.name AND e.settlement_id IS NULL AND julianday(e.event_time) >= julianday(:startTime)',
{ startTime }
)
.select('s.id', 'id') .select('s.id', 'id')
.addSelect('s.name', 'name') .addSelect('s.name', 'name')
.addSelect('s.score', 'score') .addSelect('s.score', 'score')
+11 -3
View File
@@ -3,6 +3,10 @@ import { MainContext } from '../context'
import { IsNull } from 'typeorm' import { IsNull } from 'typeorm'
import { ScoreEventEntity, SettlementEntity, StudentEntity } from '../db/entities' import { ScoreEventEntity, SettlementEntity, StudentEntity } from '../db/entities'
function isPostgres(ds: any): boolean {
return ds.options?.type === 'postgres'
}
export interface settlementSummary { export interface settlementSummary {
id: number id: number
start_time: string start_time: string
@@ -64,7 +68,9 @@ export class SettlementRepository extends Service {
} }
async findAll(): Promise<settlementSummary[]> { 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) .getRepository(SettlementEntity)
.createQueryBuilder('s') .createQueryBuilder('s')
.select('s.id', 'id') .select('s.id', 'id')
@@ -76,7 +82,7 @@ export class SettlementRepository extends Service {
.from(ScoreEventEntity, 'e') .from(ScoreEventEntity, 'e')
.where('e.settlement_id = s.id') .where('e.settlement_id = s.id')
}, 'event_count') }, 'event_count')
.orderBy('julianday(s.end_time)', 'DESC') .orderBy(orderBy, 'DESC')
const rows = await qb.getRawMany() const rows = await qb.getRawMany()
return rows.map((r: any) => ({ return rows.map((r: any) => ({
@@ -100,10 +106,12 @@ export class SettlementRepository extends Service {
const endTime = new Date().toISOString() const endTime = new Date().toISOString()
const settlementsRepo = manager.getRepository(SettlementEntity) 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 const lastSettlement = await settlementsRepo
.createQueryBuilder('s') .createQueryBuilder('s')
.select('s.end_time', 'end_time') .select('s.end_time', 'end_time')
.orderBy('julianday(s.end_time)', 'DESC') .orderBy(orderBy, 'DESC')
.limit(1) .limit(1)
.getRawOne<{ end_time?: string }>() .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', kind: 'string',
defaultValue: 'light-default', defaultValue: 'light-default',
writePermission: 'admin' 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' }, { type: 'separator' },
{
label: '重启应用',
click: () => {
this.restartApp()
}
},
{ type: 'separator' },
{ {
label: '关闭应用', label: '关闭应用',
click: () => { click: () => {
@@ -55,4 +62,9 @@ export class TrayService extends Service {
this.mainCtx.windows.open({ key: 'main', title: 'SecScore', route: '/' }) 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'), 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 // HTTP Server
httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) => httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) =>
ipcRenderer.invoke('http:server:start', config), ipcRenderer.invoke('http:server:start', config),
+14
View File
@@ -32,6 +32,8 @@ export type settingsSpec = {
auto_score_enabled: boolean auto_score_enabled: boolean
auto_score_rules: any[] auto_score_rules: any[]
current_theme_id: string current_theme_id: string
pg_connection_string: string
pg_connection_status: { connected: boolean; type: 'sqlite' | 'postgresql'; error?: string }
} }
export type settingsKey = keyof settingsSpec export type settingsKey = keyof settingsSpec
@@ -176,6 +178,18 @@ export interface electronApi {
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>> 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 // HTTP Server
httpServerStart: (config?: { httpServerStart: (config?: {
port?: number port?: number
+80 -70
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 { useEffect, useMemo, useState } from 'react'
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom' import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { ContentArea } from './components/ContentArea' import { ContentArea } from './components/ContentArea'
import { Wizard } from './components/Wizard' import { Wizard } from './components/Wizard'
import { ThemeProvider } from './contexts/ThemeContext' import { ThemeProvider, useTheme } from './contexts/ThemeContext'
import { ThemeEditorProvider } from './contexts/ThemeEditorContext' import { ThemeEditorProvider } from './contexts/ThemeEditorContext'
import { ThemeEditor } from './components/ThemeEditor' import { ThemeEditor } from './components/ThemeEditor'
import { useTheme } from './contexts/ThemeContext'
function MainContent(): React.JSX.Element { function MainContent(): React.JSX.Element {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { currentTheme } = useTheme() const { currentTheme } = useTheme()
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => { useEffect(() => {
if (!(window as any).api) return if (!(window as any).api) return
const unlisten = (window as any).api.onNavigate((route: string) => { const unlisten = (window as any).api.onNavigate((route: string) => {
// 统一路径格式进行比对,防止 / 和 /home 导致重复跳转
const currentPath = location.pathname === '/' ? '/home' : location.pathname const currentPath = location.pathname === '/' ? '/home' : location.pathname
const targetPath = route === '/' ? '/home' : route const targetPath = route === '/' ? '/home' : route
@@ -83,9 +82,9 @@ function MainContent(): React.JSX.Element {
setPermission(res.data.permission) setPermission(res.data.permission)
setAuthVisible(false) setAuthVisible(false)
setAuthPassword('') setAuthPassword('')
MessagePlugin.success('权限已解锁') messageApi.success('权限已解锁')
} else { } 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() const res = await (window as any).api.authLogout()
if (res?.success && res.data) { if (res?.success && res.data) {
setPermission(res.data.permission) setPermission(res.data.permission)
MessagePlugin.success('已切换为只读') messageApi.success('已切换为只读')
} }
} }
const onMenuChange = (v: string | number) => { const onMenuChange = (v: string) => {
const key = String(v) const key = String(v)
if (key === 'home') navigate('/') if (key === 'home') navigate('/')
if (key === 'students') navigate('/students') if (key === 'students') navigate('/students')
@@ -110,77 +109,90 @@ function MainContent(): React.JSX.Element {
if (key === 'settings') navigate('/settings') if (key === 'settings') navigate('/settings')
} }
const isDark = currentTheme?.mode === 'dark'
return ( return (
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}> <ConfigProvider
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} /> theme={{
<ContentArea algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
permission={permission} token: {
hasAnyPassword={hasAnyPassword} colorPrimary: 'var(--ant-color-primary, #1890ff)'
onAuthClick={() => setAuthVisible(true)} }
onLogout={logout} }}
/> >
{contextHolder}
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
<ContentArea
permission={permission}
hasAnyPassword={hasAnyPassword}
onAuthClick={() => setAuthVisible(true)}
onLogout={logout}
/>
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} /> <Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Dialog <Modal
header="权限解锁" title="权限解锁"
visible={authVisible} open={authVisible}
onClose={() => setAuthVisible(false)} onCancel={() => setAuthVisible(false)}
onConfirm={login} onOk={login}
confirmBtn={{ content: '解锁', loading: authLoading }} confirmLoading={authLoading}
> okText="解锁"
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> cancelText="取消"
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
6 ==
</div>
<Input
value={authPassword}
onChange={(v) => setAuthPassword(v)}
placeholder="例如 123456"
maxlength={6}
/>
</div>
</Dialog>
{import.meta.env.DEV ? (
<div
style={{
position: 'fixed',
display: 'flex',
bottom: '2px',
left: '20px',
opacity: 0.6,
zIndex: 9999
}}
> >
<p <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
6 ==
</div>
<Input
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="例如 123456"
maxLength={6}
/>
</div>
</Modal>
{import.meta.env.DEV ? (
<div
style={{ style={{
color: '#df0000', position: 'fixed',
fontWeight: 'bold', display: 'flex',
fontSize: '14px', bottom: '2px',
pointerEvents: 'none' left: '20px',
opacity: 0.6,
zIndex: 9999
}} }}
> >
, <p
</p> style={{
<p color: '#df0000',
style={{ fontWeight: 'bold',
color: currentTheme?.mode === 'dark' ? '#fff' : '#44474b', fontSize: '14px',
fontWeight: 'bold', pointerEvents: 'none'
fontSize: '13px', }}
paddingLeft: '5px' >
}} ,
> </p>
SecScore Dev ({getPlatform()}-{getArchitecture()}) <p
</p> style={{
</div> color: currentTheme?.mode === 'dark' ? '#fff' : '#44474b',
) : null} fontWeight: 'bold',
</Layout> fontSize: '13px',
paddingLeft: '5px'
}}
>
SecScore Dev ({getPlatform()}-{getArchitecture()})
</p>
</div>
) : null}
</Layout>
</ConfigProvider>
) )
} }
function getArchitecture(): string { function getArchitecture(): string {
// 尝试从 userAgent 中获取架构信息
const userAgent = navigator.userAgent.toLowerCase() const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('arm64') || userAgent.includes('aarch64')) { if (userAgent.includes('arm64') || userAgent.includes('aarch64')) {
@@ -191,12 +203,10 @@ function getArchitecture(): string {
return 'x86' return 'x86'
} }
// 默认返回未知架构
return userAgent return userAgent
} }
function getPlatform(): string { function getPlatform(): string {
// 尝试从 userAgent 中获取平台信息
const userAgent = navigator.userAgent.toLowerCase() const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('windows')) { if (userAgent.includes('windows')) {
+21 -62
View File
@@ -1,14 +1,4 @@
@import 'tdesign-react/dist/reset.css'; @import 'antd/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或者人类会用顺便帮我改一下 */
html, html,
body, body,
@@ -17,11 +7,11 @@ body,
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
user-select: none; /* 禁用文本选择 */ user-select: none;
} }
/* 如果某些输入框需要允许选择,可以单独开启 */ input,
input, textarea { textarea {
user-select: text; user-select: text;
} }
@@ -29,79 +19,48 @@ input, textarea {
color: var(--ss-sidebar-text, var(--ss-text-main)); color: var(--ss-sidebar-text, var(--ss-text-main));
} }
.ss-sidebar .t-menu__item, .ss-sidebar .ant-menu {
.ss-sidebar .t-menu__item-icon { background-color: transparent !important;
}
.ss-sidebar .ant-menu-item {
color: var(--ss-sidebar-text, var(--ss-text-main)); color: var(--ss-sidebar-text, var(--ss-text-main));
} }
.ss-sidebar .t-menu__item--active, .ss-sidebar .ant-menu-item-selected {
.ss-sidebar .t-menu__item.t-is-active, background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
.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 {
color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main))); color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main)));
} }
.ss-sidebar .t-menu__item--active, html[theme-mode='dark'] .ss-sidebar .ant-menu-item-selected {
.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 {
color: #ffffff !important; 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) { @media (pointer: coarse) {
.ss-sidebar .t-menu__item { .ss-sidebar .ant-menu-item {
min-height: 48px; min-height: 48px;
font-size: 16px; font-size: 16px;
} }
.t-button { .ant-btn {
min-height: 44px; min-height: 44px;
} }
.t-input, .ant-input,
.t-input__inner, .ant-select-selector,
.t-select-input, .ant-input-number {
.t-input-number,
.t-input-number__input {
min-height: 44px; min-height: 44px;
font-size: 16px; font-size: 16px;
} }
.t-table th, .ant-table th,
.t-table td { .ant-table td {
padding-top: 14px; padding-top: 14px;
padding-bottom: 14px; padding-bottom: 14px;
} }
} }
.ss-table-center .t-table th, .ss-table-center .ant-table th,
.ss-table-center .t-table td, .ss-table-center .ant-table td {
.ss-table-center .t-table th .t-table__cell,
.ss-table-center .t-table td .t-table__cell {
text-align: center; text-align: center;
justify-content: center;
} }
+103 -111
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
import type { TriggerItem, ActionItem } from './com.automatically/types' import type { TriggerItem, ActionItem } from './com.automatically/types'
import TriggerItemComponent from './com.automatically/TriggerItem' import TriggerItemComponent from './com.automatically/TriggerItem'
@@ -9,15 +9,16 @@ import {
Form, Form,
Input, Input,
Button, Button,
MessagePlugin, message,
Table, Table,
PrimaryTableCol,
Space, Space,
Switch, Switch,
Popconfirm, Popconfirm,
Select, Select,
TooltipLite Tooltip,
} from 'tdesign-react' Pagination
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface AutoScoreRule { interface AutoScoreRule {
id: number id: number
@@ -44,6 +45,7 @@ export const AutoScoreManager: React.FC = () => {
const [editingRuleId, setEditingRuleId] = useState<number | null>(null) const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [triggerList, setTriggerList] = useState<TriggerItem[]>([]) const [triggerList, setTriggerList] = useState<TriggerItem[]>([])
const [actionList, setActionList] = useState<ActionItem[]>([]) const [actionList, setActionList] = useState<ActionItem[]>([])
const [messageApi, contextHolder] = message.useMessage()
const fetchRules = async () => { const fetchRules = async () => {
if (!(window as any).api) return if (!(window as any).api) return
@@ -53,7 +55,7 @@ export const AutoScoreManager: React.FC = () => {
try { try {
const authRes = await (window as any).api.authGetStatus() const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号') messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
setLoading(false) setLoading(false)
return return
} }
@@ -68,14 +70,14 @@ export const AutoScoreManager: React.FC = () => {
if (rulesRes.success) { if (rulesRes.success) {
setRules(rulesRes.data) setRules(rulesRes.data)
} else { } else {
MessagePlugin.error(rulesRes.message || '获取自动化失败') messageApi.error(rulesRes.message || '获取自动化失败')
} }
if (studentsRes.success) { if (studentsRes.success) {
setStudents(studentsRes.data) setStudents(studentsRes.data)
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch auto score rules:', error) console.error('Failed to fetch auto score rules:', error)
MessagePlugin.error('获取自动化失败') messageApi.error('获取自动化失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -91,17 +93,17 @@ export const AutoScoreManager: React.FC = () => {
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
if (!values.name) { if (!values.name) {
MessagePlugin.warning('请填写自动化名称') messageApi.warning('请填写自动化名称')
return return
} }
if (triggerList.length === 0) { if (triggerList.length === 0) {
MessagePlugin.warning('请至少添加一个触发器') messageApi.warning('请至少添加一个触发器')
return return
} }
if (actionList.length === 0) { if (actionList.length === 0) {
MessagePlugin.warning('请至少添加一个行动') messageApi.warning('请至少添加一个行动')
return return
} }
@@ -129,7 +131,7 @@ export const AutoScoreManager: React.FC = () => {
try { try {
const authRes = await (window as any).api.authGetStatus() const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以创建或更新自动加分自动化') messageApi.error('需要管理员权限以创建或更新自动加分自动化')
return return
} }
} catch (e) { } catch (e) {
@@ -148,7 +150,7 @@ export const AutoScoreManager: React.FC = () => {
} }
if (res.success) { if (res.success) {
MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功') messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
form.setFieldsValue({ form.setFieldsValue({
name: '', name: '',
studentNames: '' studentNames: ''
@@ -158,13 +160,13 @@ export const AutoScoreManager: React.FC = () => {
setActionList([]) setActionList([])
fetchRules() fetchRules()
} else { } else {
MessagePlugin.error( messageApi.error(
res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败') res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
) )
} }
} catch (error) { } catch (error) {
console.error('Failed to submit auto score rule:', 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 { try {
const authRes = await (window as any).api.authGetStatus() const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以删除自动加分自动化') messageApi.error('需要管理员权限以删除自动加分自动化')
return return
} }
} catch (e) { } catch (e) {
@@ -213,14 +215,14 @@ export const AutoScoreManager: React.FC = () => {
try { try {
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId) const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
if (res.success) { if (res.success) {
MessagePlugin.success('自动化删除成功') messageApi.success('自动化删除成功')
fetchRules() fetchRules()
} else { } else {
MessagePlugin.error(res.message || '删除自动化失败') messageApi.error(res.message || '删除自动化失败')
} }
} catch (error) { } catch (error) {
console.error('Failed to delete auto score rule:', error) console.error('Failed to delete auto score rule:', error)
MessagePlugin.error('删除自动化失败') messageApi.error('删除自动化失败')
} }
} }
@@ -229,7 +231,7 @@ export const AutoScoreManager: React.FC = () => {
try { try {
const authRes = await (window as any).api.authGetStatus() const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以启用/禁用自动加分自动化') messageApi.error('需要管理员权限以启用/禁用自动加分自动化')
return return
} }
} catch (e) { } catch (e) {
@@ -239,14 +241,14 @@ export const AutoScoreManager: React.FC = () => {
try { try {
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled }) const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
if (res.success) { if (res.success) {
MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用') messageApi.success(enabled ? '自动化已启用' : '自动化已禁用')
fetchRules() fetchRules()
} else { } else {
MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败')) messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
} }
} catch (error) { } catch (error) {
console.error('Failed to toggle auto score rule:', 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 nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
const defaultTrigger = allTriggers.list[0] const defaultTrigger = allTriggers.list[0]
if (!defaultTrigger) { if (!defaultTrigger) {
MessagePlugin.error('没有可用的触发器类型,请检查配置') messageApi.error('没有可用的触发器类型,请检查配置')
return return
} }
setTriggerList((prev) => [ 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 nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
const defaultAction = allActions.list[0] const defaultAction = allActions.list[0]
if (!defaultAction) { if (!defaultAction) {
MessagePlugin.error('没有可用的行动类型,请检查配置') messageApi.error('没有可用的行动类型,请检查配置')
return return
} }
setActionList((prev) => [ setActionList((prev) => [
@@ -328,98 +330,89 @@ export const AutoScoreManager: React.FC = () => {
setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a))) setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a)))
} }
const columns: PrimaryTableCol<AutoScoreRule>[] = [ const columns: ColumnsType<AutoScoreRule> = [
{ {
colKey: 'drag', key: 'drag',
title: '排序', title: '排序',
cell: () => <MoveIcon />, width: 60,
width: 60 render: () => <HolderOutlined style={{ cursor: 'move' }} />
}, },
{ {
colKey: 'enabled',
title: '状态', title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 80, width: 80,
cell: ({ row }) => ( render: (enabled: boolean, row) => (
<Switch <Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
value={row.enabled}
onChange={(value) => handleToggle(row.id, value)}
size="small"
/>
) )
}, },
{ colKey: 'name', title: '自动化名称', width: 150 }, { title: '自动化名称', dataIndex: 'name', key: 'name', width: 150 },
{ {
colKey: 'triggers',
title: '触发器', title: '触发器',
dataIndex: 'triggers',
key: 'triggers',
width: 150, width: 150,
cell: ({ row }) => { render: (triggers: AutoScoreRule['triggers']) => {
if (!row.triggers || row.triggers.length === 0) { if (!triggers || triggers.length === 0) {
return <span></span> return <span></span>
} }
const triggerLabels = row.triggers.map((t) => { const triggerLabels = triggers.map((t) => {
const def = triggerRegistry.get(t.event) const def = triggerRegistry.get(t.event)
return def?.label || t.event return def?.label || t.event
}) })
return ( return (
<TooltipLite <Tooltip title={triggerLabels.join(', ')}>
content={triggerLabels.join(', ')} <span>{triggers.length} </span>
showArrow </Tooltip>
placement="mouse"
theme="default"
>
{row.triggers.length}
</TooltipLite>
) )
} }
}, },
{ {
colKey: 'actions',
title: '行动', title: '行动',
dataIndex: 'actions',
key: 'actions',
width: 150, width: 150,
cell: ({ row }) => { render: (actions: AutoScoreRule['actions']) => {
if (!row.actions || row.actions.length === 0) { if (!actions || actions.length === 0) {
return <span></span> return <span></span>
} }
const actionLabels = row.actions.map((a) => { const actionLabels = actions.map((a) => {
const def = actionRegistry.get(a.event) const def = actionRegistry.get(a.event)
return def?.label || a.event return def?.label || a.event
}) })
return ( return (
<TooltipLite <Tooltip title={actionLabels.join(', ')}>
content={actionLabels.join(', ')} <span>{actions.length} </span>
showArrow </Tooltip>
placement="mouse"
theme="default"
>
{row.actions.length}
</TooltipLite>
) )
} }
}, },
{ {
colKey: 'studentNames',
title: '适用学生', title: '适用学生',
dataIndex: 'studentNames',
key: 'studentNames',
width: 130, width: 130,
cell: ({ row }) => { render: (studentNames: string[]) => {
if (!row.studentNames || row.studentNames.length === 0) { if (!studentNames || studentNames.length === 0) {
return <span></span> return <span></span>
} }
const studentList = row.studentNames.join(',\n') const studentList = studentNames.join(',\n')
return ( return (
<TooltipLite content={studentList} showArrow placement="mouse" theme="default"> <Tooltip title={studentList}>
{row.studentNames.length} <span>{studentNames.length} </span>
</TooltipLite> </Tooltip>
) )
} }
}, },
{ {
colKey: 'lastExecuted',
title: '最后执行', title: '最后执行',
dataIndex: 'lastExecuted',
key: 'lastExecuted',
width: 180, width: 180,
cell: ({ row }) => { render: (lastExecuted: string) => {
if (!row.lastExecuted) return <span></span> if (!lastExecuted) return <span></span>
try { try {
const date = new Date(row.lastExecuted) const date = new Date(lastExecuted)
return date.toLocaleString() return date.toLocaleString()
} catch { } catch {
return <span></span> return <span></span>
@@ -427,16 +420,16 @@ export const AutoScoreManager: React.FC = () => {
} }
}, },
{ {
colKey: 'operation',
title: '操作', title: '操作',
key: 'operation',
width: 150, width: 150,
cell: ({ row }) => ( render: (_, row) => (
<Space> <Space>
<Button size="small" variant="outline" onClick={() => handleEdit(row)}> <Button size="small" onClick={() => handleEdit(row)}>
</Button> </Button>
<Popconfirm content="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}> <Popconfirm title="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
<Button size="small" variant="outline" theme="danger"> <Button size="small" danger>
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -445,8 +438,6 @@ export const AutoScoreManager: React.FC = () => {
} }
] ]
const onDragSort = (params: any) => setRules(params.newData)
const triggerItems = triggerList const triggerItems = triggerList
.filter((t) => t.eventName !== null) .filter((t) => t.eventName !== null)
.map((item, idx) => ( .map((item, idx) => (
@@ -476,34 +467,35 @@ export const AutoScoreManager: React.FC = () => {
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2> <h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}> <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' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem <Form.Item
label="自动化名称" label="自动化名称"
name="name" name="name"
rules={[{ required: true, message: '请输入自动化名称' }]} rules={[{ required: true, message: '请输入自动化名称' }]}
> >
<Input placeholder="例如:每日签到加分" /> <Input placeholder="例如:每日签到加分" />
</Form.FormItem> </Form.Item>
<Form.FormItem label="适用学生" name="studentNames"> <Form.Item label="适用学生" name="studentNames">
<Select <Select
filterable mode="multiple"
multiple showSearch
placeholder="请选择或搜索学生(留空表示所有学生)" placeholder="请选择或搜索学生(留空表示所有学生)"
options={students.map((student) => ({ label: student.name, value: student.name }))} options={students.map((student) => ({ label: student.name, value: student.name }))}
/> />
</Form.FormItem> </Form.Item>
</div> </div>
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}> <div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
<Button theme="primary" onClick={handleSubmit}> <Button type="primary" onClick={handleSubmit}>
{editingRuleId !== null ? '更新自动化' : '添加自动化'} {editingRuleId !== null ? '更新自动化' : '添加自动化'}
</Button> </Button>
<Button type="reset" variant="outline"> <Button onClick={handleResetForm}>
{editingRuleId !== null ? '取消编辑' : '重置表单'} {editingRuleId !== null ? '取消编辑' : '重置表单'}
</Button> </Button>
</div> </div>
@@ -513,16 +505,14 @@ export const AutoScoreManager: React.FC = () => {
<Card <Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }} style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="当以下规则触发时" title="当以下规则触发时"
headerBordered
> >
<Space style={{ display: 'grid' }}> <Space orientation="vertical" style={{ width: '100%' }}>
{triggerItems} {triggerItems}
<Button <Button
theme="default" type="dashed"
variant="text" icon={<PlusOutlined />}
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
onClick={handleAddTrigger} onClick={handleAddTrigger}
style={{ fontWeight: 'bolder', fontSize: 15 }}
> >
</Button> </Button>
@@ -532,16 +522,14 @@ export const AutoScoreManager: React.FC = () => {
<Card <Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }} style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="满足规则时触发的行动" title="满足规则时触发的行动"
headerBordered
> >
<Space style={{ display: 'grid' }}> <Space orientation="vertical" style={{ width: '100%' }}>
{actionItems} {actionItems}
<Button <Button
theme="default" type="dashed"
variant="text" icon={<PlusOutlined />}
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
onClick={handleAddAction} onClick={handleAddAction}
style={{ fontWeight: 'bolder', fontSize: 15 }}
> >
</Button> </Button>
@@ -550,22 +538,26 @@ export const AutoScoreManager: React.FC = () => {
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}> <Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Table <Table
data={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)} dataSource={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
resizable
loading={loading} loading={loading}
dragSort="row-handler" pagination={false}
onDragSort={onDragSort}
pagination={{
current: currentPage,
pageSize,
total: rules.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
style={{ color: 'var(--ss-text-main)' }} 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> </Card>
</div> </div>
) )
+5 -6
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy } from 'react' 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 { Routes, Route, Navigate } from 'react-router-dom'
import { WindowControls } from './WindowControls' import { WindowControls } from './WindowControls'
import { ThemeEditor } from './ThemeEditor' import { ThemeEditor } from './ThemeEditor'
@@ -38,8 +38,7 @@ export function ContentArea({
}: ContentAreaProps): React.JSX.Element { }: ContentAreaProps): React.JSX.Element {
const permissionTag = ( const permissionTag = (
<Tag <Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'} color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
> >
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'} {permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag> </Tag>
@@ -83,10 +82,10 @@ export function ContentArea({
{permissionTag} {permissionTag}
{hasAnyPassword && ( {hasAnyPassword && (
<> <>
<Button size="small" variant="outline" onClick={onAuthClick}> <Button size="small" onClick={onAuthClick}>
</Button> </Button>
<Button size="small" variant="outline" theme="danger" onClick={onLogout}> <Button size="small" danger onClick={onLogout}>
</Button> </Button>
</> </>
@@ -107,7 +106,7 @@ export function ContentArea({
height: '100%' height: '100%'
}} }}
> >
<Loading text="正在载入页面..." /> <Spin size="large" description="正在载入页面..." />
</div> </div>
} }
> >
+15 -16
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react' 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 { interface scoreEvent {
id: number id: number
@@ -30,26 +31,26 @@ export const EventHistory: React.FC = () => {
fetchEvents() fetchEvents()
}, []) }, [])
const columns: PrimaryTableCol<scoreEvent>[] = [ const columns: ColumnsType<scoreEvent> = [
{ colKey: 'student_name', title: '学生姓名', width: 120 }, { title: '学生姓名', dataIndex: 'student_name', key: 'student_name', width: 120 },
{ colKey: 'reason_content', title: '积分理由', width: 200 }, { title: '积分理由', dataIndex: 'reason_content', key: 'reason_content', width: 200 },
{ {
colKey: 'delta',
title: '分值变动', title: '分值变动',
dataIndex: 'delta',
key: 'delta',
width: 100, width: 100,
cell: ({ row }) => ( render: (delta: number) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light"> <Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
) )
}, },
{ colKey: 'val_prev', title: '原分值', width: 100 }, { title: '原分值', dataIndex: 'val_prev', key: 'val_prev', width: 100 },
{ colKey: 'val_curr', title: '新分值', width: 100 }, { title: '新分值', dataIndex: 'val_curr', key: 'val_curr', width: 100 },
{ {
colKey: 'event_time',
title: '发生时间', title: '发生时间',
dataIndex: 'event_time',
key: 'event_time',
width: 180, 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' }}> <div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2> <h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<Table <Table
data={data} dataSource={data}
columns={columns} columns={columns}
rowKey="uuid" rowKey="uuid"
loading={loading} loading={loading}
bordered bordered
hover
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }} 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)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/> />
</div> </div>
+57 -116
View File
@@ -1,17 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
Card, import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
Space,
Button,
Tag,
Input,
Select,
Dialog,
MessagePlugin,
InputNumber,
Divider
} from 'tdesign-react'
import { SearchIcon, DeleteIcon } from 'tdesign-icons-react'
import { match, pinyin } from 'pinyin-pro' import { match, pinyin } from 'pinyin-pro'
interface student { interface student {
@@ -38,34 +27,29 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [sortType, setSortType] = useState<SortType>('alphabet') const [sortType, setSortType] = useState<SortType>('alphabet')
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
// 滚动容器引用
const scrollContainerRef = useRef<HTMLDivElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({}) const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 操作框状态
const [selectedStudent, setSelectedStudent] = useState<student | null>(null) const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [operationVisible, setOperationVisible] = useState(false) const [operationVisible, setOperationVisible] = useState(false)
const [customScore, setCustomScore] = useState<number | undefined>(undefined) const [customScore, setCustomScore] = useState<number | undefined>(undefined)
const [reasonContent, setReasonContent] = useState('') const [reasonContent, setReasonContent] = useState('')
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => { const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
} }
// 获取姓氏
const getSurname = (name: string) => { const getSurname = (name: string) => {
if (!name) return '' if (!name) return ''
return name.charAt(0) return name.charAt(0)
} }
// 获取拼音首字母
const getFirstLetter = (name: string) => { const getFirstLetter = (name: string) => {
if (!name) return '' if (!name) return ''
const firstChar = name.charAt(0) const firstChar = name.charAt(0)
// 如果是英文字母
if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase() if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase()
// 如果是中文,转拼音
const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' }) const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' })
return py ? py.toUpperCase() : '#' 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) return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchData]) }, [fetchData])
// 获取展示用的文字
const getDisplayText = (name: string) => { const getDisplayText = (name: string) => {
if (!name) return '' if (!name) return ''
return name.length > 2 ? name.substring(name.length - 2) : name return name.length > 2 ? name.substring(name.length - 2) : name
} }
// 拼音匹配
const matchStudentName = useCallback((s: student, keyword: string) => { const matchStudentName = useCallback((s: student, keyword: string) => {
const q0 = keyword.trim().toLowerCase() const q0 = keyword.trim().toLowerCase()
if (!q0) return true if (!q0) return true
@@ -136,7 +118,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return false return false
}, []) }, [])
// 过滤和排序学生
const sortedStudents = useMemo(() => { const sortedStudents = useMemo(() => {
const filtered = students.filter((s) => matchStudentName(s, searchKeyword)) const filtered = students.filter((s) => matchStudentName(s, searchKeyword))
@@ -163,7 +144,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
}, [students, searchKeyword, sortType, matchStudentName]) }, [students, searchKeyword, sortType, matchStudentName])
// 分组显示
const groupedStudents = useMemo(() => { const groupedStudents = useMemo(() => {
if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) { if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) {
return [{ key: 'all', students: sortedStudents }] return [{ key: 'all', students: sortedStudents }]
@@ -181,7 +161,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
.map(([key, students]) => ({ key, students })) .map(([key, students]) => ({ key, students }))
}, [sortedStudents, sortType, searchKeyword]) }, [sortedStudents, sortType, searchKeyword])
// 按分类分组的理由
const groupedReasons = useMemo(() => { const groupedReasons = useMemo(() => {
const groups: Record<string, reason[]> = {} const groups: Record<string, reason[]> = {}
reasons.forEach((r) => { reasons.forEach((r) => {
@@ -196,7 +175,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}) })
}, [reasons]) }, [reasons])
// 生成头像颜色
const getAvatarColor = (name: string) => { const getAvatarColor = (name: string) => {
const colors = [ const colors = [
'#FF6B6B', '#FF6B6B',
@@ -218,7 +196,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return colors[index] return colors[index]
} }
// 跳转到指定分组
const scrollToGroup = (key: string) => { const scrollToGroup = (key: string) => {
const element = groupRefs.current[key] const element = groupRefs.current[key]
if (element) { if (element) {
@@ -226,10 +203,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
} }
// 打开操作框
const openOperation = (student: student) => { const openOperation = (student: student) => {
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
setSelectedStudent(student) setSelectedStudent(student)
@@ -238,11 +214,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setOperationVisible(true) setOperationVisible(true)
} }
// 核心提交逻辑
const performSubmit = async (student: student, delta: number, content: string) => { const performSubmit = async (student: student, delta: number, content: string) => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
@@ -254,29 +229,26 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}) })
if (res.success) { if (res.success) {
MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`) messageApi.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
setOperationVisible(false) setOperationVisible(false)
// 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置
setStudents((prev) => setStudents((prev) =>
prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s)) prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s))
) )
// 通知其他组件数据已更新(但不在此处重复 fetchData)
emitDataUpdated('events') emitDataUpdated('events')
} else { } else {
MessagePlugin.error(res.message || '提交失败') messageApi.error(res.message || '提交失败')
} }
setSubmitLoading(false) setSubmitLoading(false)
} }
// 手动点击确定按钮提交(用于自定义分值)
const handleSubmit = async () => { const handleSubmit = async () => {
if (!selectedStudent) return if (!selectedStudent) return
const delta = customScore const delta = customScore
if (delta === undefined || !Number.isFinite(delta)) { if (delta === undefined || !Number.isFinite(delta)) {
MessagePlugin.warning('请选择或输入分值') messageApi.warning('请选择或输入分值')
return return
} }
@@ -284,18 +256,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
await performSubmit(selectedStudent, delta, content) await performSubmit(selectedStudent, delta, content)
} }
// 快捷理由选择:点击即提交
const handleReasonSelect = (reason: reason) => { const handleReasonSelect = (reason: reason) => {
if (!selectedStudent) return if (!selectedStudent) return
performSubmit(selectedStudent, reason.delta, reason.content) performSubmit(selectedStudent, reason.delta, reason.content)
} }
// 渲染学生卡片
const renderStudentCard = (student: student, index: number) => { const renderStudentCard = (student: student, index: number) => {
const avatarText = getDisplayText(student.name) const avatarText = getDisplayText(student.name)
const avatarColor = getAvatarColor(student.name) const avatarColor = getAvatarColor(student.name)
// 排行榜勋章
let rankBadge: string | null = null let rankBadge: string | null = null
if (sortType === 'score' && !searchKeyword) { if (sortType === 'score' && !searchKeyword) {
if (index === 0) rankBadge = '🥇' if (index === 0) rankBadge = '🥇'
@@ -316,6 +285,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)', border: '1px solid var(--ss-border-color)',
overflow: 'visible' overflow: 'visible'
}} }}
styles={{ body: { padding: '12px' } }}
> >
{rankBadge && ( {rankBadge && (
<div <div
@@ -364,9 +334,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
<Tag <Tag
theme={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'} color={student.score > 0 ? 'success' : student.score < 0 ? 'error' : 'default'}
variant="light-outline"
size="small"
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
> >
{student.score > 0 ? `+${student.score}` : student.score} {student.score > 0 ? `+${student.score}` : student.score}
@@ -379,7 +347,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
) )
} }
// 渲染分组学生卡片
const renderGroupedCards = () => { const renderGroupedCards = () => {
return groupedStudents.map((group) => ( return groupedStudents.map((group) => (
<div <div
@@ -399,11 +366,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
borderLeft: '4px solid var(--td-brand-color)', borderLeft: '4px solid var(--ant-color-primary, #1890ff)',
paddingLeft: '12px' paddingLeft: '12px'
}} }}
> >
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span> <span style={{ color: 'var(--ant-color-primary, #1890ff)' }}>{group.key}</span>
<span <span
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }} 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 navContainerRef = useRef<HTMLDivElement>(null)
const isNavDragging = useRef(false) const isNavDragging = useRef(false)
@@ -437,7 +403,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const itemCount = items.length const itemCount = items.length
if (itemCount === 0) return if (itemCount === 0) return
// 计算当前指向第几个项
const itemHeight = rect.height / itemCount const itemHeight = rect.height / itemCount
const index = Math.floor(y / itemHeight) const index = Math.floor(y / itemHeight)
const safeIndex = Math.max(0, Math.min(itemCount - 1, index)) 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) document.removeEventListener('mouseup', onGlobalMouseUp)
} }
// 触摸事件处理
const onNavTouchStart = (e: React.TouchEvent) => { const onNavTouchStart = (e: React.TouchEvent) => {
isNavDragging.current = true isNavDragging.current = true
if (e.touches[0]) { if (e.touches[0]) {
@@ -480,7 +444,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const onNavTouchMove = (e: React.TouchEvent) => { const onNavTouchMove = (e: React.TouchEvent) => {
if (isNavDragging.current && e.touches[0]) { if (isNavDragging.current && e.touches[0]) {
handleNavAction(e.touches[0].clientY) handleNavAction(e.touches[0].clientY)
// 防止触摸滑动时触发页面滚动
if (e.cancelable) e.preventDefault() if (e.cancelable) e.preventDefault()
} }
} }
@@ -489,7 +452,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
isNavDragging.current = false isNavDragging.current = false
} }
// 渲染快速导航
const renderQuickNav = () => { const renderQuickNav = () => {
if ( if (
groupedStudents.length <= 1 || groupedStudents.length <= 1 ||
@@ -521,7 +483,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)', border: '1px solid var(--ss-border-color)',
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
touchAction: 'none' // 关键:禁用浏览器的默认触摸处理 touchAction: 'none'
}} }}
> >
{groupedStudents.map((group) => ( {groupedStudents.map((group) => (
@@ -535,9 +497,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
justifyContent: 'center', justifyContent: 'center',
fontSize: '11px', fontSize: '11px',
fontWeight: 'bold', fontWeight: 'bold',
color: 'var(--td-brand-color)', color: 'var(--ant-color-primary, #1890ff)',
borderRadius: '50%', borderRadius: '50%',
pointerEvents: 'none' // 让事件由父容器统一处理 pointerEvents: 'none'
}} }}
> >
{group.key} {group.key}
@@ -549,7 +511,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return ( return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}> <div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
{/* 顶部工具栏 */} {contextHolder}
<div <div
style={{ style={{
marginBottom: '32px', marginBottom: '32px',
@@ -569,35 +531,31 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</p> </p>
</div> </div>
<Space size="medium"> <Space size="middle">
{/* 搜索 */}
<Input <Input
value={searchKeyword} value={searchKeyword}
onChange={setSearchKeyword} onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索姓名/拼音..." placeholder="搜索姓名/拼音..."
prefixIcon={<SearchIcon />} prefix={<SearchOutlined />}
clearable allowClear
style={{ width: '220px' }} style={{ width: '220px' }}
/> />
{/* 排序方式 */}
<Select <Select
value={sortType} value={sortType}
onChange={(v) => setSortType(v as SortType)} onChange={(v) => setSortType(v as SortType)}
style={{ width: '140px' }} style={{ width: '140px' }}
autoWidth options={[
> { value: 'alphabet', label: '姓名排序' },
<Select.Option value="alphabet" label="姓名排序" /> { value: 'surname', label: '姓氏分组' },
<Select.Option value="surname" label="姓氏分组" /> { value: 'score', label: '积分排行' }
<Select.Option value="score" label="积分排行" /> ]}
</Select> />
</Space> </Space>
</div> </div>
{/* 快速导航 */}
{renderQuickNav()} {renderQuickNav()}
{/* 学生卡片网格 */}
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}> <div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}> <div style={{ textAlign: 'center', padding: '100px 0' }}>
@@ -617,12 +575,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'} {searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div> </div>
{searchKeyword && ( {searchKeyword && (
<Button <Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
variant="text"
theme="primary"
onClick={() => setSearchKeyword('')}
style={{ marginTop: '8px' }}
>
</Button> </Button>
)} )}
@@ -632,20 +585,19 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)} )}
</div> </div>
{/* 操作框 */} <Modal
<Dialog title={`积分操作:${selectedStudent?.name}`}
header={`积分操作:${selectedStudent?.name}`} open={operationVisible}
visible={operationVisible} onCancel={() => setOperationVisible(false)}
onClose={() => setOperationVisible(false)} onOk={handleSubmit}
onConfirm={handleSubmit} confirmLoading={submitLoading}
confirmBtn={{ content: '提交操作', loading: submitLoading }} okText="提交操作"
width="560px" cancelText="取消"
destroyOnClose width={560}
top="10%" destroyOnHidden
> >
{selectedStudent && ( {selectedStudent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
{/* 当前状态 */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -680,14 +632,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</span> </span>
<Tag <Tag
theme={ color={
selectedStudent.score > 0 selectedStudent.score > 0
? 'success' ? 'success'
: selectedStudent.score < 0 : selectedStudent.score < 0
? 'danger' ? 'error'
: 'default' : 'default'
} }
variant="light"
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
> >
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
@@ -695,7 +646,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div> </div>
</div> </div>
{/* 快捷理由 */}
{groupedReasons.length > 0 && ( {groupedReasons.length > 0 && (
<div> <div>
<div <div
@@ -731,19 +681,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
> >
{category} {category}
</div> </div>
<Space breakLine size="small"> <Space wrap size="small">
{items.map((r) => ( {items.map((r) => (
<Button <Button
key={r.id} key={r.id}
variant="outline"
size="small" size="small"
onClick={() => handleReasonSelect(r)} onClick={() => handleReasonSelect(r)}
style={{ style={{
borderColor: borderColor:
r.delta > 0 r.delta > 0
? 'var(--td-success-color-3)' ? 'var(--ant-color-success, #52c41a)'
: r.delta < 0 : r.delta < 0
? 'var(--td-error-color-3)' ? 'var(--ant-color-error, #ff4d4f)'
: undefined : undefined
}} }}
> >
@@ -753,9 +702,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
marginLeft: '4px', marginLeft: '4px',
color: color:
r.delta > 0 r.delta > 0
? 'var(--td-success-color)' ? 'var(--ant-color-success, #52c41a)'
: r.delta < 0 : r.delta < 0
? 'var(--td-error-color)' ? 'var(--ant-color-error, #ff4d4f)'
: 'inherit', : 'inherit',
fontWeight: 'bold' fontWeight: 'bold'
}} }}
@@ -771,7 +720,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div> </div>
)} )}
{/* 自定义分值 */}
<div> <div>
<div <div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }} style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -784,20 +732,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Button <Button
key={num} key={num}
size="small" size="small"
variant={customScore === num ? 'base' : 'outline'} type={customScore === num ? 'primary' : 'default'}
theme={num > 0 ? 'success' : 'danger'} danger={num < 0}
onClick={() => setCustomScore(num)} onClick={() => setCustomScore(num)}
style={{ minWidth: '42px' }} style={{ minWidth: '42px' }}
> >
{num > 0 ? `+${num}` : num} {num > 0 ? `+${num}` : num}
</Button> </Button>
))} ))}
<Button <Button size="small" onClick={() => setCustomScore(0)} style={{ minWidth: '42px' }}>
size="small"
variant="outline"
onClick={() => setCustomScore(0)}
style={{ minWidth: '42px' }}
>
0 0
</Button> </Button>
</div> </div>
@@ -817,7 +760,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div> </div>
</div> </div>
{/* 理由内容 */}
<div> <div>
<div <div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }} style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -827,12 +769,12 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div> </div>
<Input <Input
value={reasonContent} value={reasonContent}
onChange={setReasonContent} onChange={(e) => setReasonContent(e.target.value)}
placeholder="输入加分/扣分的原因(可选)" placeholder="输入加分/扣分的原因(可选)"
suffixIcon={ suffix={
reasonContent ? ( reasonContent ? (
<DeleteIcon <DeleteOutlined
onClick={() => setSearchKeyword('')} onClick={() => setReasonContent('')}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
) : undefined ) : undefined
@@ -840,19 +782,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
/> />
</div> </div>
{/* 变动预览 */}
{customScore !== undefined && ( {customScore !== undefined && (
<div <div
style={{ style={{
padding: '16px', padding: '16px',
backgroundColor: backgroundColor:
customScore > 0 customScore > 0
? 'var(--td-success-color-1)' ? 'var(--ant-color-success-bg, #f6ffed)'
: customScore < 0 : customScore < 0
? 'var(--td-error-color-1)' ? 'var(--ant-color-error-bg, #fff2f0)'
: 'var(--ss-bg-color)', : 'var(--ss-bg-color)',
borderRadius: '8px', 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' marginTop: '4px'
}} }}
> >
@@ -873,9 +814,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fontWeight: 'bold', fontWeight: 'bold',
color: color:
customScore > 0 customScore > 0
? 'var(--td-success-color)' ? 'var(--ant-color-success, #52c41a)'
: customScore < 0 : customScore < 0
? 'var(--td-error-color)' ? 'var(--ant-color-error, #ff4d4f)'
: 'inherit' : 'inherit'
}} }}
> >
@@ -890,7 +831,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)} )}
</div> </div>
)} )}
</Dialog> </Modal>
</div> </div>
) )
} }
+38 -56
View File
@@ -1,16 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
Table, import type { ColumnsType } from 'antd/es/table'
PrimaryTableCol, import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
Tag,
Button,
Select,
Space,
Card,
MessagePlugin,
Dialog
} from 'tdesign-react'
import { ViewListIcon, DownloadIcon } from 'tdesign-icons-react'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
interface studentRank { interface studentRank {
@@ -28,6 +19,7 @@ export const Leaderboard: React.FC = () => {
const [historyVisible, setHistoryVisible] = useState(false) const [historyVisible, setHistoryVisible] = useState(false)
const [historyHeader, setHistoryHeader] = useState('') const [historyHeader, setHistoryHeader] = useState('')
const [historyText, setHistoryText] = useState('') const [historyText, setHistoryText] = useState('')
const [messageApi, contextHolder] = message.useMessage()
const fetchRankings = useCallback(async () => { const fetchRankings = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
@@ -66,7 +58,7 @@ export const Leaderboard: React.FC = () => {
startTime startTime
}) })
if (!res.success) { if (!res.success) {
MessagePlugin.error(res.message || '查询失败') messageApi.error(res.message || '查询失败')
return return
} }
@@ -82,7 +74,6 @@ export const Leaderboard: React.FC = () => {
} }
const handleExport = () => { const handleExport = () => {
// 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
setTimeout(() => { setTimeout(() => {
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月' const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
@@ -125,18 +116,18 @@ export const Leaderboard: React.FC = () => {
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
MessagePlugin.success('导出成功') messageApi.success('导出成功')
}, 0) }, 0)
} }
const columns: PrimaryTableCol<studentRank>[] = [ const columns: ColumnsType<studentRank> = [
{ {
colKey: 'rank',
title: '排名', title: '排名',
key: 'rank',
width: 70, width: 70,
align: 'center', align: 'center',
cell: ({ rowIndex }) => { render: (_, __, index) => {
const rank = rowIndex + 1 const rank = index + 1
let color = 'inherit' let color = 'inherit'
if (rank === 1) color = '#FFD700' if (rank === 1) color = '#FFD700'
if (rank === 2) color = '#C0C0C0' 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: '总积分', title: '总积分',
dataIndex: 'score',
key: 'score',
width: 100, width: 100,
align: 'center', 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' ? '本周变化' : '本月变化', title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
dataIndex: 'range_change',
key: 'range_change',
width: 100, width: 100,
align: 'center', align: 'center',
cell: ({ row }) => ( render: (change: number) => (
<Tag <Tag color={change > 0 ? 'success' : change < 0 ? 'error' : 'default'}>
theme={row.range_change > 0 ? 'success' : row.range_change < 0 ? 'danger' : 'default'} {change > 0 ? `+${change}` : change}
variant="light"
>
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
</Tag> </Tag>
) )
}, },
{ {
colKey: 'operation',
title: '操作记录', title: '操作记录',
key: 'operation',
width: 100, width: 100,
align: 'center', align: 'center',
cell: ({ row }) => ( render: (_, row) => (
<Button <Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
variant="text"
theme="primary"
icon={<ViewListIcon />}
onClick={() => handleViewHistory(row.name)}
>
</Button> </Button>
) )
@@ -190,6 +175,7 @@ export const Leaderboard: React.FC = () => {
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<div <div
style={{ style={{
marginBottom: '24px', marginBottom: '24px',
@@ -202,14 +188,15 @@ export const Leaderboard: React.FC = () => {
<Space> <Space>
<Select <Select
value={timeRange} value={timeRange}
onChange={(v) => setTimeRange(v as string)} onChange={(v) => setTimeRange(v)}
style={{ width: '120px' }} style={{ width: '120px' }}
> options={[
<Select.Option value="today" label="今天" /> { value: 'today', label: '今天' },
<Select.Option value="week" label="本周" /> { value: 'week', label: '本周' },
<Select.Option value="month" label="本月" /> { value: 'month', label: '本月' }
</Select> ]}
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExport}> />
<Button icon={<DownloadOutlined />} onClick={handleExport}>
XLSX XLSX
</Button> </Button>
</Space> </Space>
@@ -217,27 +204,22 @@ export const Leaderboard: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}> <Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table <Table
data={data} dataSource={data}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
bordered bordered
hover
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }} 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)' }} style={{ color: 'var(--ss-text-main)' }}
/> />
</Card> </Card>
<Dialog <Modal
header={historyHeader} title={historyHeader}
visible={historyVisible} open={historyVisible}
onCancel={() => setHistoryVisible(false)}
footer={<Button onClick={() => setHistoryVisible(false)}></Button>}
width="80%" width="80%"
cancelBtn={null}
confirmBtn="关闭"
onClose={() => setHistoryVisible(false)}
onConfirm={() => setHistoryVisible(false)}
> >
<div <div
style={{ style={{
@@ -254,7 +236,7 @@ export const Leaderboard: React.FC = () => {
> >
{historyText} {historyText}
</div> </div>
</Dialog> </Modal>
</div> </div>
) )
} }
+69 -104
View File
@@ -1,15 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
Table, import type { ColumnsType } from 'antd/es/table'
PrimaryTableCol,
Button,
Space,
Dialog,
Form,
Input,
MessagePlugin,
Tag
} from 'tdesign-react'
interface reason { interface reason {
id: number id: number
@@ -23,10 +14,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<reason[]>([]) const [data, setData] = useState<reason[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [visible, setVisible] = 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 [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'reasons' | 'all') => { const emitDataUpdated = (category: 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
@@ -60,15 +49,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => { const handleAdd = async () => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
const values = form.getFieldsValue?.(true) as any const values = await form.validateFields()
const content = values.content?.trim() const content = values.content?.trim()
const category = values.category?.trim() || '其他' const category = values.category?.trim() || '其他'
if (data.some((r) => r.content === content && r.category === category)) { if (data.some((r) => r.content === content && r.category === category)) {
MessagePlugin.warning('该分类下已存在相同理由') messageApi.warning('该分类下已存在相同理由')
return return
} }
@@ -79,148 +68,124 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
delta: Number(values.delta) delta: Number(values.delta)
}) })
if (res.success) { if (res.success) {
MessagePlugin.success('添加成功') messageApi.success('添加成功')
setVisible(false) setVisible(false)
form.reset() form.resetFields()
fetchReasons() fetchReasons()
emitDataUpdated('reasons') emitDataUpdated('reasons')
} else { } 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 (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
setDeleteTarget(row) const res = await (window as any).api.deleteReason(id)
setDeleteDialogVisible(true) if (res.success) {
messageApi.success('删除成功')
fetchReasons()
emitDataUpdated('reasons')
} else {
messageApi.error(res.message || '删除失败')
}
} }
const columns: PrimaryTableCol<reason>[] = [ const columns: ColumnsType<reason> = [
{ {
colKey: 'category',
title: '分类', title: '分类',
dataIndex: 'category',
key: 'category',
width: 120, 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: '预设分值', title: '预设分值',
dataIndex: 'delta',
key: 'delta',
width: 100, width: 100,
cell: ({ row }) => ( render: (delta: number) => (
<span <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> </span>
) )
}, },
{ {
colKey: 'operation',
title: '操作', title: '操作',
key: 'operation',
width: 150, width: 150,
cell: ({ row }) => ( render: (_, row) => (
<Space> <Popconfirm
<Button title="确认删除该理由?"
theme="danger" onConfirm={() => handleDelete(row.id)}
variant="text" disabled={!canEdit}
disabled={!canEdit} >
onClick={() => handleDelete(row)} <Button type="link" danger disabled={!canEdit}>
>
</Button> </Button>
</Space> </Popconfirm>
) )
} }
] ]
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2> <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> </Button>
</div> </div>
<Table <Table
data={data} dataSource={data}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
bordered bordered
hover
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }} 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)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/> />
<Dialog <Modal
header="添加理由" title="添加理由"
visible={visible} open={visible}
onConfirm={handleAdd} onOk={handleAdd}
onClose={() => setVisible(false)} onCancel={() => setVisible(false)}
destroyOnClose okText="添加"
cancelText="取消"
destroyOnHidden
> >
<Form form={form} labelWidth={80}> <Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.FormItem label="分类" name="category" initialData="其他"> <Form.Item label="分类" name="category" initialValue="其他">
<Input placeholder="例如: 学习, 纪律" /> <Input placeholder="例如: 学习, 纪律" />
</Form.FormItem> </Form.Item>
<Form.FormItem label="理由内容" name="content"> <Form.Item
label="理由内容"
name="content"
rules={[{ required: true, message: '请输入理由内容' }]}
>
<Input placeholder="请输入理由" /> <Input placeholder="请输入理由" />
</Form.FormItem> </Form.Item>
<Form.FormItem label="预设分值" name="delta"> <Form.Item
<Input type="number" placeholder="例如: 2 或 -2" /> label="预设分值"
</Form.FormItem> name="delta"
rules={[{ required: true, message: '请输入预设分值' }]}
>
<InputNumber placeholder="例如: 2 或 -2" style={{ width: '100%' }} />
</Form.Item>
</Form> </Form>
</Dialog> </Modal>
<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 || '删除失败')
}
}}
>
<div style={{ wordBreak: 'break-all' }}>
{deleteTarget
? `${deleteTarget.category} / ${deleteTarget.content} (${
deleteTarget.delta > 0 ? `+${deleteTarget.delta}` : deleteTarget.delta
})`
: ''}
</div>
</Dialog>
</div> </div>
) )
} }
+61 -76
View File
@@ -6,16 +6,15 @@ import {
Input, Input,
InputNumber, InputNumber,
Button, Button,
MessagePlugin, message,
Card, Card,
Collapse,
Table, Table,
PrimaryTableCol,
Tag, Tag,
Space, Space,
Popconfirm Popconfirm
} from 'tdesign-react' } from 'antd'
import { RollbackIcon } from 'tdesign-icons-react' import type { ColumnsType } from 'antd/es/table'
import { UndoOutlined } from '@ant-design/icons'
import { match } from 'pinyin-pro' import { match } from 'pinyin-pro'
const normalizeSearch = (input: unknown) => const normalizeSearch = (input: unknown) =>
@@ -86,6 +85,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => { const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) 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 () => { const fetchData = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
// 使用 setTimeout 避免 UI 阻塞
setTimeout(async () => { setTimeout(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -134,17 +133,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
const values = form.getFieldsValue(true) as any const values = form.getFieldsValue(true) as any
// 支持多选学生
const studentNames = Array.isArray(values.student_name) const studentNames = Array.isArray(values.student_name)
? values.student_name ? values.student_name
: [values.student_name] : [values.student_name]
if (!studentNames || studentNames.length === 0 || !values.reason_content) { if (!studentNames || studentNames.length === 0 || !values.reason_content) {
MessagePlugin.warning('请填写完整信息') messageApi.warning('请填写完整信息')
return 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 const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
if (!hasDeltaInput && !selectedReason) { if (!hasDeltaInput && !selectedReason) {
MessagePlugin.warning('请填写分值或选择预设理由') messageApi.warning('请填写分值或选择预设理由')
return return
} }
@@ -166,7 +164,6 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
: Math.abs(deltaInput) : Math.abs(deltaInput)
: Number(selectedReason?.delta ?? 0) : Number(selectedReason?.delta ?? 0)
// 为每个选中的学生创建事件
try { try {
let successCount = 0 let successCount = 0
for (const studentName of studentNames) { for (const studentName of studentNames) {
@@ -181,7 +178,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
if (successCount === studentNames.length) { if (successCount === studentNames.length) {
MessagePlugin.success(`已为 ${successCount} 名学生提交积分`) messageApi.success(`已为 ${successCount} 名学生提交积分`)
form.setFieldsValue({ form.setFieldsValue({
student_name: [], student_name: [],
delta: undefined, delta: undefined,
@@ -192,7 +189,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fetchData() fetchData()
emitDataUpdated('events') emitDataUpdated('events')
} else { } else {
MessagePlugin.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`) messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
fetchData() fetchData()
emitDataUpdated('events') emitDataUpdated('events')
} }
@@ -204,48 +201,48 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleUndo = async (uuid: string) => { const handleUndo = async (uuid: string) => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
const res = await (window as any).api.deleteEvent(uuid) const res = await (window as any).api.deleteEvent(uuid)
if (res.success) { if (res.success) {
MessagePlugin.success('已撤销操作') messageApi.success('已撤销操作')
fetchData() fetchData()
emitDataUpdated('events') emitDataUpdated('events')
} else { } else {
MessagePlugin.error(res.message || '撤销失败') messageApi.error(res.message || '撤销失败')
} }
} }
const columns: PrimaryTableCol<scoreEvent>[] = [ const columns: ColumnsType<scoreEvent> = [
{ colKey: 'student_name', title: '学生', width: 100 }, { title: '学生', dataIndex: 'student_name', key: 'student_name', width: 100 },
{ {
colKey: 'delta',
title: '变动', title: '变动',
dataIndex: 'delta',
key: 'delta',
width: 80, width: 80,
cell: ({ row }) => ( render: (delta: number) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light"> <Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
) )
}, },
{ colKey: 'reason_content', title: '理由', ellipsis: true }, { title: '理由', dataIndex: 'reason_content', key: 'reason_content', ellipsis: true },
{ {
colKey: 'event_time',
title: '时间', title: '时间',
dataIndex: 'event_time',
key: 'event_time',
width: 160, width: 160,
cell: ({ row }) => new Date(row.event_time).toLocaleString() render: (time: string) => new Date(time).toLocaleString()
}, },
{ {
colKey: 'operation',
title: '操作', title: '操作',
key: 'operation',
width: 80, width: 80,
cell: ({ row }) => ( render: (_, row) => (
<Popconfirm <Popconfirm
content="确定要撤销这条记录吗?学生积分将回滚。" title="确定要撤销这条记录吗?学生积分将回滚。"
onConfirm={() => handleUndo(row.uuid)} onConfirm={() => handleUndo(row.uuid)}
> >
<Button variant="text" theme="warning" disabled={!canEdit} icon={<RollbackIcon />}> <Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -255,43 +252,37 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2> <h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}> <Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form <Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
form={form}
labelWidth={80}
initialData={{ type: 'add' }}
onReset={() => form.setFieldsValue({ type: 'add' })}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem label="姓名" name="student_name"> <Form.Item label="姓名" name="student_name">
<Select <Select
filterable mode="multiple"
multiple showSearch
placeholder="请选择或搜索学生" placeholder="请选择或搜索学生"
filter={(filterWords, option) => filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
matchStudentName(getOptionLabel(option), filterWords)
}
options={students.map((s) => ({ label: s.name, value: s.name }))} options={students.map((s) => ({ label: s.name, value: s.name }))}
/> />
</Form.FormItem> </Form.Item>
<Form.FormItem label="分数"> <Form.Item label="分数">
<Space> <Space>
<Form.FormItem name="type" style={{ marginBottom: 0 }}> <Form.Item name="type" noStyle>
<Radio.Group variant="default-filled"> <Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="add"></Radio.Button> <Radio.Button value="add"></Radio.Button>
<Radio.Button value="subtract"></Radio.Button> <Radio.Button value="subtract"></Radio.Button>
</Radio.Group> </Radio.Group>
</Form.FormItem> </Form.Item>
<Form.FormItem name="delta" style={{ marginBottom: 0 }}> <Form.Item name="delta" noStyle>
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} /> <InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
</Form.FormItem> </Form.Item>
</Space> </Space>
</Form.FormItem> </Form.Item>
<Form.FormItem label="快捷理由" name="reason_id"> <Form.Item label="快捷理由" name="reason_id">
<Select <Select
placeholder="选择预设理由" placeholder="选择预设理由"
onChange={(v) => { onChange={(v) => {
@@ -317,23 +308,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
type: reason.delta > 0 ? 'add' : 'subtract' type: reason.delta > 0 ? 'add' : 'subtract'
}) })
}} }}
> options={reasons.map((r) => ({
{reasons.map((r) => ( label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
<Select.Option key={r.id} value={r.id} label={r.content}> value: r.id
{r.content} ({r.delta > 0 ? `+${r.delta}` : r.delta}) }))}
</Select.Option> />
))} </Form.Item>
</Select>
</Form.FormItem>
<Form.FormItem label="理由内容" name="reason_content"> <Form.Item label="理由内容" name="reason_content">
<Input placeholder="手动输入或选择快捷理由" /> <Input placeholder="手动输入或选择快捷理由" />
</Form.FormItem> </Form.Item>
</div> </div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}> <div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
<Button <Button
theme="primary" type="primary"
size="large" size="large"
disabled={!canEdit} disabled={!canEdit}
onClick={handleSubmit} onClick={handleSubmit}
@@ -347,20 +336,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Card> </Card>
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}> <Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Collapse defaultValue={[]} expandMutex> <div style={{ fontWeight: 600, marginBottom: 16 }}></div>
<Collapse.Panel header="最近记录" value="recent"> <Table
<Table dataSource={events}
data={events} columns={columns}
columns={columns} rowKey="uuid"
rowKey="uuid" loading={loading}
loading={loading} size="small"
size="small" pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }} style={{ color: 'var(--ss-text-main)' }}
scroll={{ type: 'virtual' }} />
style={{ color: 'var(--ss-text-main)' }}
/>
</Collapse.Panel>
</Collapse>
</Card> </Card>
</div> </div>
) )
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, MessagePlugin, Space, Table } from 'tdesign-react' import { Button, Card, message, Space, Table } from 'antd'
import type { PrimaryTableCol } from 'tdesign-react' import type { ColumnsType } from 'antd/es/table'
interface settlementSummary { interface settlementSummary {
id: number id: number
@@ -26,6 +26,7 @@ export const SettlementHistory: React.FC = () => {
} | null>(null) } | null>(null)
const [rows, setRows] = useState<settlementLeaderboardRow[]>([]) const [rows, setRows] = useState<settlementLeaderboardRow[]>([])
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const formatRange = (s: { start_time: string; end_time: string }) => { const formatRange = (s: { start_time: string; end_time: string }) => {
const start = new Date(s.start_time).toLocaleString() const start = new Date(s.start_time).toLocaleString()
@@ -39,7 +40,7 @@ export const SettlementHistory: React.FC = () => {
try { try {
const res = await (window as any).api.querySettlements() const res = await (window as any).api.querySettlements()
if (!res.success) { if (!res.success) {
MessagePlugin.error(res.message || '查询失败') messageApi.error(res.message || '查询失败')
return return
} }
setSettlements(res.data || []) setSettlements(res.data || [])
@@ -48,7 +49,7 @@ export const SettlementHistory: React.FC = () => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [messageApi])
useEffect(() => { useEffect(() => {
fetchSettlements() fetchSettlements()
@@ -70,7 +71,7 @@ export const SettlementHistory: React.FC = () => {
try { try {
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id }) const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
if (!res.success || !res.data) { if (!res.success || !res.data) {
MessagePlugin.error(res.message || '查询失败') messageApi.error(res.message || '查询失败')
return return
} }
setSelectedSettlement(res.data.settlement) 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: '排名', title: '排名',
key: 'rank',
width: 70, width: 70,
align: 'center', 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: '阶段积分', title: '阶段积分',
dataIndex: 'score',
key: 'score',
width: 120, 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) { if (selectedId !== null && selectedSettlement) {
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<Space style={{ marginBottom: '16px' }}> <Space style={{ marginBottom: '16px' }}>
<Button <Button
variant="outline"
onClick={() => { onClick={() => {
setSelectedId(null) setSelectedId(null)
setSelectedSettlement(null) setSelectedSettlement(null)
@@ -124,14 +126,12 @@ export const SettlementHistory: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}> <Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table <Table
data={rows} dataSource={rows}
columns={columns} columns={columns}
rowKey="name" rowKey="name"
loading={detailLoading} loading={detailLoading}
bordered bordered
hover
pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }} pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ color: 'var(--ss-text-main)' }} style={{ color: 'var(--ss-text-main)' }}
/> />
</Card> </Card>
@@ -141,6 +141,7 @@ export const SettlementHistory: React.FC = () => {
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2> <h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<div <div
style={{ style={{
@@ -161,7 +162,7 @@ export const SettlementHistory: React.FC = () => {
{formatRange(s)} {formatRange(s)}
</div> </div>
<Space> <Space>
<Button theme="primary" onClick={() => openSettlement(s.id)}> <Button type="primary" onClick={() => openSettlement(s.id)}>
</Button> </Button>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}> <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 { import {
UserIcon, UserOutlined,
SettingIcon, SettingOutlined,
HistoryIcon, HistoryOutlined,
RootListIcon, UnorderedListOutlined,
ViewListIcon, HomeOutlined,
HomeIcon, SyncOutlined,
ReplayIcon FileTextOutlined,
} from 'tdesign-icons-react' CloudOutlined,
UploadOutlined
} from '@ant-design/icons'
import { useState, useEffect } from 'react'
import appLogo from '../assets/logoHD.svg' import appLogo from '../assets/logoHD.svg'
const { Aside } = Layout const { Sider } = Layout
interface SidebarProps { interface SidebarProps {
activeMenu: string activeMenu: string
permission: 'admin' | 'points' | 'view' 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 { 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 ( return (
<Aside <Sider
className="ss-sidebar" className="ss-sidebar"
width={200}
style={{ style={{
backgroundColor: 'var(--ss-sidebar-bg)', backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)', borderRight: '1px solid var(--ss-border-color)',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}} }}
theme="light"
> >
<div <div
style={ style={
@@ -67,34 +203,78 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}> <Menu
<Menu.MenuItem value="home" icon={<HomeIcon />}> mode="inline"
{' '} selectedKeys={[activeMenu]}
onClick={({ key }) => onMenuChange(key)}
</Menu.MenuItem> style={{
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}> width: '100%',
border: 'none',
</Menu.MenuItem> backgroundColor: 'transparent'
<Menu.MenuItem value="score" icon={<HistoryIcon />}> }}
items={menuItems}
</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>
</div> </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>
) )
} }
+119 -124
View File
@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Tag } from 'tdesign-react' import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
import type { PrimaryTableCol } from 'tdesign-react' import type { ColumnsType } from 'antd/es/table'
import { TagEditorDialog } from './TagEditorDialog' import { TagEditorDialog } from './TagEditorDialog'
// 创建 XLSX Worker
const createXlsxWorker = () => { const createXlsxWorker = () => {
return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), { return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
type: 'module' type: 'module'
@@ -35,8 +34,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const xlsxInputRef = useRef<HTMLInputElement | null>(null) const xlsxInputRef = useRef<HTMLInputElement | null>(null)
const xlsxWorkerRef = useRef<Worker | null>(null) const xlsxWorkerRef = useRef<Worker | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
// 初始化 Worker
useEffect(() => { useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker() xlsxWorkerRef.current = createXlsxWorker()
return () => { return () => {
@@ -106,36 +105,31 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => { const handleAdd = async () => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
try { try {
const validateResult = await form.validate() const values = await form.validateFields()
if (validateResult !== true) {
return
}
const values = form.getFieldsValue(true) as { name: string }
if (!values.name) { if (!values.name) {
MessagePlugin.warning('请输入姓名') messageApi.warning('请输入姓名')
return return
} }
const name = values.name.trim() const name = values.name.trim()
if (data.some((s) => s.name === name)) { if (data.some((s) => s.name === name)) {
MessagePlugin.warning('学生姓名已存在') messageApi.warning('学生姓名已存在')
return return
} }
const res = await (window as any).api.createStudent({ ...values, name }) const res = await (window as any).api.createStudent({ ...values, name })
if (res.success) { if (res.success) {
MessagePlugin.success('添加成功') messageApi.success('添加成功')
setVisible(false) setVisible(false)
form.reset() form.resetFields()
fetchStudents() fetchStudents()
emitDataUpdated('students') emitDataUpdated('students')
} else { } else {
MessagePlugin.error(res.message || '添加失败') messageApi.error(res.message || '添加失败')
} }
} catch (err) { } catch (err) {
try { try {
@@ -155,22 +149,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
const res = await (window as any).api.deleteStudent(id) const res = await (window as any).api.deleteStudent(id)
if (res.success) { if (res.success) {
MessagePlugin.success('删除成功') messageApi.success('删除成功')
fetchStudents() fetchStudents()
emitDataUpdated('students') emitDataUpdated('students')
} else { } else {
MessagePlugin.error(res.message || '删除失败') messageApi.error(res.message || '删除失败')
} }
} }
const handleOpenTagEditor = (student: student) => { const handleOpenTagEditor = (student: student) => {
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
setEditingStudent(student) setEditingStudent(student)
@@ -183,18 +177,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try { try {
const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds) const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds)
if (res && res.success) { if (res && res.success) {
MessagePlugin.success('标签保存成功') messageApi.success('标签保存成功')
setTagEditVisible(false) setTagEditVisible(false)
setEditingStudent(null) setEditingStudent(null)
fetchStudents() fetchStudents()
emitDataUpdated('students') emitDataUpdated('students')
} else { } else {
const errorMsg = res?.message || '保存标签失败' const errorMsg = res?.message || '保存标签失败'
MessagePlugin.error(errorMsg) messageApi.error(errorMsg)
} }
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(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) => { const parseXlsxFile = async (file: File) => {
if (!xlsxWorkerRef.current) { if (!xlsxWorkerRef.current) {
MessagePlugin.error('Worker 未初始化') messageApi.error('Worker 未初始化')
return return
} }
@@ -219,13 +213,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try { try {
const buf = await file.arrayBuffer() const buf = await file.arrayBuffer()
// 使用 Worker 处理文件解析,避免阻塞主线程
xlsxWorkerRef.current.postMessage({ xlsxWorkerRef.current.postMessage({
type: 'parseXlsx', type: 'parseXlsx',
data: { buffer: buf } data: { buffer: buf }
}) })
// 监听 Worker 消息
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'success') { if (event.data.type === 'success') {
setXlsxFileName(file.name) setXlsxFileName(file.name)
@@ -235,7 +227,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setImportVisible(false) setImportVisible(false)
setXlsxLoading(false) setXlsxLoading(false)
} else if (event.data.type === 'error') { } else if (event.data.type === 'error') {
MessagePlugin.error(event.data.error || '解析 xlsx 失败') messageApi.error(event.data.error || '解析 xlsx 失败')
setXlsxLoading(false) setXlsxLoading(false)
} }
xlsxWorkerRef.current?.removeEventListener('message', handleMessage) xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
@@ -243,7 +235,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
xlsxWorkerRef.current.addEventListener('message', handleMessage) xlsxWorkerRef.current.addEventListener('message', handleMessage)
} catch (e: any) { } catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败') messageApi.error(e?.message || '解析 xlsx 失败')
setXlsxLoading(false) setXlsxLoading(false)
} }
} }
@@ -269,26 +261,34 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}, [xlsxAoa, xlsxMaxCols]) }, [xlsxAoa, xlsxMaxCols])
const xlsxPreviewColumns = useMemo(() => { const xlsxPreviewColumns = useMemo(() => {
const cols: PrimaryTableCol<any>[] = [ const cols: any[] = [
{ colKey: '__row', title: '#', width: 60, align: 'center', fixed: 'left' as any } {
title: '#',
dataIndex: '__row',
key: '__row',
width: 60,
align: 'center' as const,
fixed: 'left' as const
}
] ]
for (let c = 0; c < xlsxMaxCols; c++) { for (let c = 0; c < xlsxMaxCols; c++) {
const selected = xlsxSelectedCol === c const selected = xlsxSelectedCol === c
cols.push({ cols.push({
colKey: `c${c}`,
title: ( title: (
<span <span
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
fontWeight: selected ? 700 : 500, fontWeight: selected ? 700 : 500,
color: selected ? 'var(--td-brand-color)' : undefined color: selected ? 'var(--ant-color-primary, #1890ff)' : undefined
}} }}
onClick={() => setXlsxSelectedCol(c)} onClick={() => setXlsxSelectedCol(c)}
> >
{excelColName(c)} {excelColName(c)}
</span> </span>
), ),
minWidth: 120 dataIndex: `c${c}`,
key: `c${c}`,
width: 120
}) })
} }
return cols return cols
@@ -313,17 +313,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleConfirmXlsxImport = async () => { const handleConfirmXlsxImport = async () => {
if (!(window as any).api) return if (!(window as any).api) return
if (!canEdit) { if (!canEdit) {
MessagePlugin.error('当前为只读权限') messageApi.error('当前为只读权限')
return return
} }
if (xlsxSelectedCol == null) { if (xlsxSelectedCol == null) {
MessagePlugin.warning('请先点击选择姓名列') messageApi.warning('请先点击选择"姓名列"')
return return
} }
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol) const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
if (!names.length) { if (!names.length) {
MessagePlugin.error('所选列未解析到可导入的姓名') messageApi.error('所选列未解析到可导入的姓名')
return return
} }
@@ -331,12 +331,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try { try {
const res = await (window as any).api.importStudentsFromXlsx({ names }) const res = await (window as any).api.importStudentsFromXlsx({ names })
if (!res?.success) { if (!res?.success) {
MessagePlugin.error(res?.message || '导入失败') messageApi.error(res?.message || '导入失败')
return return
} }
const inserted = Number(res?.data?.inserted ?? 0) const inserted = Number(res?.data?.inserted ?? 0)
const skipped = Number(res?.data?.skipped ?? 0) const skipped = Number(res?.data?.skipped ?? 0)
MessagePlugin.success(`导入完成:新增 ${inserted},跳过 ${skipped}`) messageApi.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
setXlsxVisible(false) setXlsxVisible(false)
setXlsxAoa([]) setXlsxAoa([])
setXlsxFileName('') setXlsxFileName('')
@@ -348,75 +348,60 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
} }
const columns: PrimaryTableCol<student>[] = [ const columns: ColumnsType<student> = [
{ colKey: 'name', title: '姓名', width: 100 }, { title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ {
colKey: 'score',
title: '当前积分', title: '当前积分',
dataIndex: 'score',
key: 'score',
width: 160, width: 160,
align: 'center', align: 'center',
cell: ({ row }) => ( render: (score: number) => (
<span <span
style={{ style={{
fontWeight: 'bold', fontWeight: 'bold',
color: color:
row.score > 0 score > 0
? 'var(--td-success-color)' ? 'var(--ant-color-success, #52c41a)'
: row.score < 0 : score < 0
? 'var(--td-error-color)' ? 'var(--ant-color-error, #ff4d4f)'
: 'inherit' : 'inherit'
}} }}
> >
{row.score > 0 ? `+${row.score}` : row.score} {score > 0 ? `+${score}` : score}
</span> </span>
) )
}, },
{ {
colKey: 'tags',
title: '标签', title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 200, width: 200,
cell: ({ row }) => { render: (tags: string[] = []) => (
const tags = row.tags || [] <Space>
return ( {tags.length === 0 ? (
<Space> <span style={{ color: 'var(--ss-text-secondary)' }}></span>
{tags.length === 0 ? ( ) : (
<span style={{ color: 'var(--ss-text-secondary)' }}></span> tags.slice(0, 3).map((tag) => (
) : ( <Tag key={tag} color="blue">
tags.slice(0, 3).map((tag) => ( {tag}
<Tag key={tag} theme="primary" size="small">
{tag}
</Tag>
))
)}
{tags.length > 3 && (
<Tag theme="default" size="small">
+{tags.length - 3}
</Tag> </Tag>
)} ))
</Space> )}
) {tags.length > 3 && <Tag>+{tags.length - 3}</Tag>}
} </Space>
)
}, },
{ {
colKey: 'operation',
title: '操作', title: '操作',
key: 'operation',
width: 150, width: 150,
cell: ({ row }) => ( render: (_, row) => (
<Space> <Space>
<Button <Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
theme="default"
variant="text"
disabled={!canEdit}
onClick={() => handleOpenTagEditor(row)}
>
</Button> </Button>
<Button <Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row.id)}
>
</Button> </Button>
</Space> </Space>
@@ -424,59 +409,69 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
] ]
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize)
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2> <h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Space> <Space>
<Button variant="outline" disabled={!canEdit} onClick={() => setImportVisible(true)}> <Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
</Button> </Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}> <Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button> </Button>
</Space> </Space>
</div> </div>
<Table <Table
data={data.slice((currentPage - 1) * pageSize, currentPage * pageSize)} dataSource={paginatedData}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
hover pagination={false}
pagination={{
current: currentPage,
pageSize,
total: data.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }} 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>
{/* 添加学生弹窗 */} <Modal
<Dialog title="添加学生"
header="添加学生" open={visible}
visible={visible} onOk={handleAdd}
onConfirm={handleAdd} onCancel={() => setVisible(false)}
onClose={() => setVisible(false)} okText="添加"
destroyOnClose cancelText="取消"
destroyOnHidden
> >
<Form form={form} labelWidth={80}> <Form form={form} layout="vertical">
<Form.FormItem label="姓名" name="name"> <Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入学生姓名" /> <Input placeholder="请输入学生姓名" />
</Form.FormItem> </Form.Item>
</Form> </Form>
</Dialog> </Modal>
<Dialog <Modal
header="导入名单" title="导入名单"
visible={importVisible} open={importVisible}
onClose={() => setImportVisible(false)} onCancel={() => setImportVisible(false)}
footer={false} footer={null}
destroyOnClose destroyOnHidden
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space orientation="vertical" style={{ width: '100%' }}>
<Button <Button
loading={xlsxLoading} loading={xlsxLoading}
disabled={!canEdit} disabled={!canEdit}
@@ -498,16 +493,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}} }}
/> />
</Space> </Space>
</Dialog> </Modal>
<Dialog <Modal
header="xlsx 预览与导入" title="xlsx 预览与导入"
visible={xlsxVisible} open={xlsxVisible}
onClose={() => setXlsxVisible(false)} onCancel={() => setXlsxVisible(false)}
confirmBtn={{ content: '导入', loading: xlsxLoading, disabled: xlsxSelectedCol == null }} onOk={handleConfirmXlsxImport}
onConfirm={handleConfirmXlsxImport} okText="导入"
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
width="80%" width="80%"
destroyOnClose destroyOnHidden
> >
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}> <div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
<div>{xlsxFileName || '-'}</div> <div>{xlsxFileName || '-'}</div>
@@ -517,17 +513,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div> 50 </div> <div> 50 </div>
</div> </div>
<Table <Table
data={xlsxPreviewRows} dataSource={xlsxPreviewRows}
columns={xlsxPreviewColumns} columns={xlsxPreviewColumns}
rowKey="__row" rowKey="__row"
bordered bordered
hover scroll={{ y: 420 }}
maxHeight={420}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
pagination={false}
/> />
</Dialog> </Modal>
{/* 标签编辑弹窗 */}
<TagEditorDialog <TagEditorDialog
visible={tagEditVisible} visible={tagEditVisible}
onClose={() => { onClose={() => {
+31 -45
View File
@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Dialog, Input, Button, Space, Tag, MessagePlugin } from 'tdesign-react' import { Modal, Input, Button, Space, Tag, message } from 'antd'
import { useTheme } from '../contexts/ThemeContext' // 导入主题上下文
interface Tag { interface TagItem {
id: number id: number
name: string name: string
} }
@@ -23,13 +22,10 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
title = '编辑标签' title = '编辑标签'
}) => { }) => {
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [allTags, setAllTags] = useState<Tag[]>([]) const [allTags, setAllTags] = useState<TagItem[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds)) 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() { async function fetchAllTags() {
if (!(window as any).api) return if (!(window as any).api) return
try { try {
@@ -39,7 +35,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch tags:', 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) return
if (trimmed.length > 30) { if (trimmed.length > 30) {
MessagePlugin.error('标签名称不能超过 30 个字符') messageApi.error('标签名称不能超过 30 个字符')
return return
} }
@@ -76,11 +72,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
setSelectedTagIds((prev) => new Set([...prev, res.data.id])) setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
setInputValue('') setInputValue('')
} else { } else {
MessagePlugin.error(res.message || '添加标签失败') messageApi.error(res.message || '添加标签失败')
} }
} catch (e) { } catch (e) {
console.error('Failed to create tag:', 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) newSet.delete(tagId)
return newSet return newSet
}) })
MessagePlugin.success('标签删除成功') messageApi.success('标签删除成功')
} else { } else {
MessagePlugin.error(res.message || '删除标签失败') messageApi.error(res.message || '删除标签失败')
} }
} catch (e) { } catch (e) {
console.error('Failed to delete tag:', 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 = () => { const handleConfirm = () => {
onConfirm(Array.from(selectedTagIds)) onConfirm(Array.from(selectedTagIds))
onClose() onClose()
@@ -132,33 +121,31 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
const availableTags = allTags.filter((t) => !selectedTagIds.has(t.id)) const availableTags = allTags.filter((t) => !selectedTagIds.has(t.id))
return ( return (
<Dialog <Modal
header={title} title={title}
visible={visible} open={visible}
onClose={onClose} onCancel={onClose}
onConfirm={handleConfirm} onOk={handleConfirm}
confirmBtn={{ content: '保存', theme: 'primary' }} okText="保存"
cancelBtn={{ content: '取消' }} cancelText="取消"
destroyOnClose destroyOnHidden
width={500} width={500}
> >
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 标签输入区 */}
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<Input <Input
placeholder="输入标签名称,按 Enter 添加" placeholder="输入标签名称,按 Enter 添加"
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={(e) => setInputValue(e.target.value)}
onEnter={handleAddTag} onPressEnter={handleAddTag}
/* maxLength={50} */
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Button theme="primary" onClick={handleAddTag} disabled={!inputValue.trim()}> <Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
</Button> </Button>
</div> </div>
{/* 已选标签区 */}
<div> <div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}> <div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -174,12 +161,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{selectedTags.length === 0 ? ( {selectedTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span> <span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : ( ) : (
<Space> <Space wrap>
{selectedTags.map((tag) => ( {selectedTags.map((tag) => (
<Tag <Tag
key={tag.id} key={tag.id}
theme="primary" color="blue"
variant={themeMode === 'dark' ? 'outline' : 'light'} // 根据主题模式动态设置变体
closable closable
onClose={() => handleToggleTag(tag.id)} onClose={() => handleToggleTag(tag.id)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -192,7 +178,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div> </div>
</div> </div>
{/* 可选标签区 */}
<div> <div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}> <div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -208,14 +193,15 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{availableTags.length === 0 ? ( {availableTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span> <span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : ( ) : (
<Space> <Space wrap>
{availableTags.map((tag) => ( {availableTags.map((tag) => (
<Tag <Tag
key={tag.id} key={tag.id}
theme="default"
variant={themeMode === 'dark' ? 'light' : 'outline'} // 根据主题模式动态设置变体
closable closable
onClose={() => handleDeleteTag(tag.id)} onClose={(e) => {
e.preventDefault()
handleDeleteTag(tag.id)
}}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => handleToggleTag(tag.id)} onClick={() => handleToggleTag(tag.id)}
> >
@@ -227,6 +213,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div> </div>
</div> </div>
</div> </div>
</Dialog> </Modal>
) )
} }
+33 -51
View File
@@ -1,16 +1,6 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { import { Drawer, Form, Input, Select, Button, Space, Divider, Row, Col, ColorPicker } from 'antd'
Drawer, import type { Color } from 'antd/es/color-picker'
Form,
Input,
Select,
ColorPicker,
Button,
Space,
Divider,
Row,
Col
} from 'tdesign-react'
import { useThemeEditor } from '../contexts/ThemeEditorContext' import { useThemeEditor } from '../contexts/ThemeEditorContext'
import { useTheme } from '../contexts/ThemeContext' import { useTheme } from '../contexts/ThemeContext'
import { generateColorMap } from '../utils/color' import { generateColorMap } from '../utils/color'
@@ -59,7 +49,6 @@ export const ThemeEditor: React.FC = () => {
const { currentTheme } = useTheme() const { currentTheme } = useTheme()
// 实时预览逻辑
useEffect(() => { useEffect(() => {
if (!isEditing || !editingTheme) return if (!isEditing || !editingTheme) return
@@ -67,10 +56,8 @@ export const ThemeEditor: React.FC = () => {
const { tdesign, custom } = theme.config const { tdesign, custom } = theme.config
const root = document.documentElement const root = document.documentElement
// 1. 设置 TDesign 模式
root.setAttribute('theme-mode', theme.mode) root.setAttribute('theme-mode', theme.mode)
// 2. 设置 TDesign 品牌色
if (tdesign.brandColor) { if (tdesign.brandColor) {
const colorMap = generateColorMap(tdesign.brandColor, theme.mode) const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
Object.entries(colorMap).forEach(([key, value]) => { Object.entries(colorMap).forEach(([key, value]) => {
@@ -78,7 +65,6 @@ export const ThemeEditor: React.FC = () => {
}) })
} }
// 3. 应用自定义变量
Object.entries(custom).forEach(([key, value]) => { Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value) root.style.setProperty(key, value)
}) })
@@ -87,7 +73,6 @@ export const ThemeEditor: React.FC = () => {
applyPreview(editingTheme) applyPreview(editingTheme)
}, [editingTheme, isEditing]) }, [editingTheme, isEditing])
// 关闭时恢复原有主题
useEffect(() => { useEffect(() => {
if (!isEditing && currentTheme) { if (!isEditing && currentTheme) {
const { tdesign, custom } = currentTheme.config const { tdesign, custom } = currentTheme.config
@@ -112,39 +97,36 @@ export const ThemeEditor: React.FC = () => {
return ( return (
<Drawer <Drawer
header="编辑主题" title="编辑主题"
visible={isEditing} open={isEditing}
onClose={cancelEditing} onClose={cancelEditing}
size="500px" width={500}
footer={ footer={
<Space> <Space>
<Button theme="primary" onClick={saveEditingTheme}> <Button type="primary" onClick={saveEditingTheme}>
</Button> </Button>
<Button theme="default" onClick={cancelEditing}> <Button onClick={cancelEditing}></Button>
</Button>
</Space> </Space>
} }
destroyOnClose destroyOnHidden
> >
<Form labelAlign="top"> <Form layout="vertical">
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space orientation="vertical" style={{ width: '100%' }} size="large">
{/* 基本信息 */}
<div> <div>
<Divider align="left"></Divider> <Divider></Divider>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={12}> <Col span={12}>
<Form.FormItem label="主题名称"> <Form.Item label="主题名称">
<Input <Input
value={editingTheme.name} value={editingTheme.name}
onChange={(v) => updateEditingTheme({ name: v })} onChange={(e) => updateEditingTheme({ name: e.target.value })}
placeholder="请输入主题名称" placeholder="请输入主题名称"
/> />
</Form.FormItem> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.FormItem label="色彩模式"> <Form.Item label="色彩模式">
<Select <Select
value={editingTheme.mode} value={editingTheme.mode}
onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })} onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })}
@@ -153,36 +135,35 @@ export const ThemeEditor: React.FC = () => {
{ label: '深色 (Dark)', value: 'dark' } { label: '深色 (Dark)', value: 'dark' }
]} ]}
/> />
</Form.FormItem> </Form.Item>
</Col> </Col>
<Col span={24}> <Col span={24}>
<Form.FormItem label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme"> <Form.Item label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
<Input <Input
value={editingTheme.id} value={editingTheme.id}
onChange={(v) => updateEditingTheme({ id: v })} onChange={(e) => updateEditingTheme({ id: e.target.value })}
placeholder="请输入主题 ID" placeholder="请输入主题 ID"
/> />
</Form.FormItem> </Form.Item>
</Col> </Col>
</Row> </Row>
</div> </div>
{/* TDesign 品牌色 */}
<div> <div>
<Divider align="left"> (Brand)</Divider> <Divider> (Brand)</Divider>
<Form.FormItem label="主品牌色" help="将自动生成一系列色阶"> <Form.Item label="主品牌色" help="将自动生成一系列色阶">
<ColorPicker <ColorPicker
value={editingTheme.config.tdesign.brandColor} value={editingTheme.config.tdesign.brandColor}
onChange={(v) => updateConfig('tdesign', 'brandColor', v)} onChange={(color: Color) =>
enableAlpha={false} updateConfig('tdesign', 'brandColor', color.toHexString())
format="HEX" }
showText
/> />
</Form.FormItem> </Form.Item>
</div> </div>
{/* 业务自定义变量 */}
<div> <div>
<Divider align="left"> (Custom)</Divider> <Divider> (Custom)</Divider>
{Object.entries(variableGroups).map(([groupKey, group]) => ( {Object.entries(variableGroups).map(([groupKey, group]) => (
<div key={groupKey} style={{ marginBottom: 24 }}> <div key={groupKey} style={{ marginBottom: 24 }}>
<div <div
@@ -190,7 +171,7 @@ export const ThemeEditor: React.FC = () => {
fontSize: '13px', fontSize: '13px',
fontWeight: 600, fontWeight: 600,
marginBottom: '12px', marginBottom: '12px',
color: 'var(--td-text-color-primary)' color: 'var(--ss-text-main)'
}} }}
> >
{group.title} {group.title}
@@ -202,7 +183,7 @@ export const ThemeEditor: React.FC = () => {
<div <div
style={{ style={{
fontSize: '12px', fontSize: '12px',
color: 'var(--td-text-color-secondary)', color: 'var(--ss-text-secondary)',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
@@ -213,9 +194,10 @@ export const ThemeEditor: React.FC = () => {
</div> </div>
<ColorPicker <ColorPicker
value={editingTheme.config.custom[item.key] || '#ffffff'} value={editingTheme.config.custom[item.key] || '#ffffff'}
onChange={(v) => updateConfig('custom', item.key, v)} onChange={(color: Color) =>
enableAlpha updateConfig('custom', item.key, color.toHexString())
format="HEX" }
showText
/> />
</div> </div>
</Col> </Col>
+15 -14
View File
@@ -1,5 +1,10 @@
import { Button } from 'tdesign-react' import { Button } from 'antd'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react' import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import electronLogo from '../assets/electron.svg' import electronLogo from '../assets/electron.svg'
@@ -11,10 +16,9 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => { 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)) ;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => { const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized) setIsMaximized(maximized)
}) })
@@ -96,33 +100,30 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
} }
> >
<Button <Button
variant="text" type="text"
shape="square"
onClick={minimize} onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
<RemoveIcon /> <MinusOutlined />
</Button> </Button>
<Button <Button
variant="text" type="text"
shape="square"
onClick={maximize} onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
{isMaximized ? ( {isMaximized ? (
<FullscreenExitIcon style={{ transform: 'scale(0.5)' }} /> <FullscreenExitOutlined style={{ transform: 'scale(0.8)' }} />
) : ( ) : (
<RectangleIcon /> <BorderOutlined style={{ transform: 'scale(0.8)' }} />
)} )}
</Button> </Button>
<Button <Button
variant="text" type="text"
shape="square"
onClick={close} onClick={close}
className="titlebar-close-btn" className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
<CloseIcon /> <CloseOutlined />
</Button> </Button>
</div> </div>
+15 -14
View File
@@ -1,15 +1,19 @@
import { Button } from 'tdesign-react' import { Button } from 'antd'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react' import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function WindowControls(): React.JSX.Element { export function WindowControls(): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => { 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)) ;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => { const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized) setIsMaximized(maximized)
}) })
@@ -43,33 +47,30 @@ export function WindowControls(): React.JSX.Element {
} }
> >
<Button <Button
variant="text" type="text"
shape="square"
onClick={minimize} onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
<RemoveIcon /> <MinusOutlined />
</Button> </Button>
<Button <Button
variant="text" type="text"
shape="square"
onClick={maximize} onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
{isMaximized ? ( {isMaximized ? (
<FullscreenExitIcon /> <FullscreenExitOutlined />
) : ( ) : (
<RectangleIcon style={{ transform: 'scale(0.7)' }} /> <BorderOutlined style={{ transform: 'scale(0.8)' }} />
)} )}
</Button> </Button>
<Button <Button
variant="text" type="text"
shape="square"
onClick={close} onClick={close}
className="titlebar-close-btn" className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }} style={{ width: '46px', height: '32px', borderRadius: 0 }}
> >
<CloseIcon /> <CloseOutlined />
</Button> </Button>
<style> <style>
{` {`
+25 -20
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react' 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' import { useTheme } from '../contexts/ThemeContext'
interface wizardProps { interface wizardProps {
@@ -10,6 +10,7 @@ interface wizardProps {
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => { export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme() const { themes, currentTheme, setTheme } = useTheme()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const handleFinish = async () => { const handleFinish = async () => {
setLoading(true) 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) const res = await (window as any).api.setSetting('is_wizard_completed', true)
if (!res?.success) throw new Error(res?.message || 'failed') if (!res?.success) throw new Error(res?.message || 'failed')
MessagePlugin.success('配置完成!') messageApi.success('配置完成!')
onComplete() onComplete()
} catch { } catch {
MessagePlugin.error('配置保存失败') messageApi.error('配置保存失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<Dialog <Modal
header="欢迎使用 SecScore 积分管理" title="欢迎使用 SecScore 积分管理"
visible={visible} open={visible}
confirmBtn={{ content: '开启积分之旅', loading }} onOk={handleFinish}
cancelBtn={null} onCancel={() => {}}
closeOnEscKeydown={false} confirmLoading={loading}
closeOnOverlayClick={false} okText="开启积分之旅"
onConfirm={handleFinish} cancelButtonProps={{ style: { display: 'none' } }}
closable={false}
mask={{ closable: false }}
keyboard={false}
width={500} width={500}
> >
{contextHolder}
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}> <Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
SecScore SecScore
</Typography.Paragraph> </Typography.Paragraph>
<Form labelWidth={100}> <Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.FormItem label="外观主题"> <Form.Item label="外观主题">
<Select value={currentTheme?.id} onChange={(v) => setTheme(v as string)}> <Select
{themes.map((t) => ( value={currentTheme?.id}
<Select.Option key={t.id} value={t.id} label={t.name} /> onChange={(v) => setTheme(v as string)}
))} options={themes.map((t) => ({ value: t.id, label: t.name }))}
</Select> />
</Form.FormItem> </Form.Item>
</Form> </Form>
</Dialog> </Modal>
) )
} }
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react' import { Button, Select } from 'antd'
import { Delete1Icon } from 'tdesign-icons-react' import { DeleteOutlined } from '@ant-design/icons'
import { actionRegistry, allActions } from './registry' import { actionRegistry, allActions } from './registry'
import type { ActionItem as ActionItemType } from './types' import type { ActionItem as ActionItemType } from './types'
@@ -23,15 +23,10 @@ const ActionItem: React.FC<ActionItemProps> = ({
return ( return (
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button <Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Select <Select
value={item.eventName} value={item.eventName}
style={{ width: '200px', marginRight: 12 }} style={{ width: '200px' }}
options={allActions.options} options={allActions.options}
placeholder="请选择触发行动" placeholder="请选择触发行动"
onChange={(value) => onChange(item.id, value as string)} onChange={(value) => onChange(item.id, value as string)}
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react' import { Button, Select } from 'antd'
import { Delete1Icon } from 'tdesign-icons-react' import { DeleteOutlined } from '@ant-design/icons'
import { triggerRegistry, allTriggers } from './registry' import { triggerRegistry, allTriggers } from './registry'
import type { TriggerItem as TriggerItemType } from './types' import type { TriggerItem as TriggerItemType } from './types'
@@ -25,25 +25,20 @@ const TriggerItem: React.FC<TriggerItemProps> = ({
const relation = item.relation || 'AND' const relation = item.relation || 'AND'
return ( return (
<div style={{ display: 'flex', gap: 5 }}> <div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
{!isFirst && ( {!isFirst && (
<Button <Button
theme={relation === 'AND' ? 'primary' : 'warning'} type={relation === 'AND' ? 'primary' : 'default'}
variant={relation === 'AND' ? 'base' : 'outline'} danger={relation === 'OR'}
onClick={() => onRelationChange?.(item.id, relation === 'AND' ? 'OR' : 'AND')} onClick={() => onRelationChange?.(item.id, relation === 'AND' ? 'OR' : 'AND')}
> >
{relation === 'AND' ? '并' : '或'} {relation === 'AND' ? '并' : '或'}
</Button> </Button>
)} )}
<Button <Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Select <Select
value={item.eventName} value={item.eventName}
style={{ width: '200px', marginRight: 12 }} style={{ width: '200px' }}
options={allTriggers.options} options={allTriggers.options}
placeholder="请选择触发规则" placeholder="请选择触发规则"
onChange={(value) => onChange(item.id, value as string)} 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' import type { ActionComponentProps } from '../types'
export const eventName = 'add_score' export const eventName = 'add_score'
@@ -18,13 +18,13 @@ const AddScoreAction: React.FC<ActionComponentProps> = ({
placeholder="请输入分数" placeholder="请输入分数"
style={{ width: '150px' }} style={{ width: '150px' }}
value={value ?? ''} value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')} onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/> />
<Input <Input
placeholder="请输入理由" placeholder="请输入理由"
style={{ width: '150px' }} style={{ width: '150px' }}
value={reason ?? ''} 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' import type { ActionComponentProps } from '../types'
export const eventName = 'add_tag' export const eventName = 'add_tag'
@@ -12,7 +12,7 @@ const AddTagAction: React.FC<ActionComponentProps> = ({ value, onChange }) => {
placeholder="请输入标签" placeholder="请输入标签"
style={{ width: '150px' }} style={{ width: '150px' }}
value={value ?? ''} 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' import type { ActionComponentProps } from '../types'
export const eventName = 'send_notification' export const eventName = 'send_notification'
@@ -12,7 +12,7 @@ const SendNotificationAction: React.FC<ActionComponentProps> = ({ value, onChang
placeholder="请输入通知内容" placeholder="请输入通知内容"
style={{ width: '150px' }} style={{ width: '150px' }}
value={value ?? ''} 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 { useState } from 'react'
import { InputNumber, Space, Radio, Form } from 'tdesign-react' import { InputNumber, Space, Radio } from 'antd'
import type { TriggerComponentProps } from '../types' import type { TriggerComponentProps } from '../types'
export const eventName = 'interval_time_passed' 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 numValue = value ? parseInt(value, 10) : undefined
const [unit, setUnit] = useState<'minutes' | 'days'>('minutes') const [unit, setUnit] = useState<'minutes' | 'days'>('minutes')
const handleChange = (v: any) => { const handleChange = (v: number | null) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined const numV = v
if (numV === undefined || numV === null || isNaN(numV)) { if (numV === undefined || numV === null || isNaN(numV)) {
onChange('') onChange('')
return return
@@ -41,37 +41,22 @@ const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange
return ( return (
<Space> <Space>
<Form.FormItem <InputNumber
name="intervalMinutes" placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'}
rules={[ style={{ width: '100px' }}
{ required: true, message: '请输入时间' }, value={displayValue}
{ min: 1, message: unit === 'minutes' ? '间隔时间至少为1分钟' : '间隔时间至少为1天' } onChange={handleChange}
]} min={1}
style={{ marginBottom: 0 }} />
<Radio.Group
value={unit}
onChange={(e) => setUnit(e.target.value as 'minutes' | 'days')}
optionType="button"
buttonStyle="solid"
> >
<InputNumber <Radio.Button value="days"></Radio.Button>
placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'} <Radio.Button value="minutes"></Radio.Button>
style={{ width: '100px' }} </Radio.Group>
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')}
>
<Radio.Button value="days"></Radio.Button>
<Radio.Button value="minutes"></Radio.Button>
</Radio.Group>
</Form.FormItem>
</Space> </Space>
) )
} }
@@ -1,4 +1,4 @@
import { InputNumber, Row, Col } from 'tdesign-react' import { InputNumber, Space } from 'antd'
import type { TriggerComponentProps } from '../types' import type { TriggerComponentProps } from '../types'
export const eventName = 'random_time_reached' export const eventName = 'random_time_reached'
@@ -33,45 +33,39 @@ const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
console.debug('RandomTimeTrigger parse error', e) console.debug('RandomTimeTrigger parse error', e)
} }
const handleChange = (key: keyof RandomTimeConfig, v: any) => { const handleChange = (key: keyof RandomTimeConfig, v: number | null) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined const numV = v ?? (key === 'minHour' ? 0 : 23)
const newConfig = { ...config, [key]: numV ?? (key === 'minHour' ? 0 : 23) } const newConfig = { ...config, [key]: numV }
onChange(JSON.stringify(newConfig)) onChange(JSON.stringify(newConfig))
} }
return ( return (
<Row gutter={8}> <Space>
<Col> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span> <InputNumber
<InputNumber placeholder="最小小时"
placeholder="最小小时" style={{ width: '70px' }}
style={{ width: '70px' }} value={config.minHour}
value={config.minHour} onChange={(v) => handleChange('minHour', v)}
onChange={(v) => handleChange('minHour', v)} min={0}
min={0} max={23}
max={23} />
theme="column" <span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
/> </div>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
</div> <span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</Col> <InputNumber
<Col> placeholder="最大小时"
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> style={{ width: '70px' }}
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span> value={config.maxHour}
<InputNumber onChange={(v) => handleChange('maxHour', v)}
placeholder="最大小时" min={0}
style={{ width: '70px' }} max={23}
value={config.maxHour} />
onChange={(v) => handleChange('maxHour', v)} <span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
min={0} </div>
max={23} </Space>
theme="column"
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Col>
</Row>
) )
} }
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Select } from 'tdesign-react' import { Select } from 'antd'
import type { TriggerComponentProps } from '../types' import type { TriggerComponentProps } from '../types'
export const eventName = 'student_tag_matched' export const eventName = 'student_tag_matched'
export const label = '当学生匹配标签时触发' export const label = '当学生匹配标签时触发'
export const description = '当学生匹配特定标签时触发自动化' export const description = '当学生匹配特定标签时触发自动化'
@@ -37,11 +38,11 @@ const StudentTagTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
<Select <Select
placeholder="请选择标签" placeholder="请选择标签"
style={{ width: '150px' }} style={{ width: '150px' }}
value={value ?? ''} value={value ?? undefined}
onChange={(v: any) => onChange(v ? String(v) : '')} onChange={(v) => onChange(v ? String(v) : '')}
options={tags} options={tags}
filterable showSearch
clearable allowClear
/> />
) )
} }

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