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