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