修复快速设置学生丢失并新增重置入口

This commit is contained in:
Linkong
2026-03-21 00:03:14 +08:00
parent 1fe9dbe66a
commit 12d6be331c
7 changed files with 4916 additions and 4693 deletions
+122 -1
View File
@@ -1,7 +1,11 @@
use parking_lot::RwLock; use parking_lot::RwLock;
use std::sync::Arc; use std::sync::Arc;
use tauri::State; use tauri::{AppHandle, Emitter, State};
use crate::db::connection::{create_sqlite_connection, DatabaseType};
use crate::db::migration::Migration;
use crate::services::settings::{SettingsKey, SettingsValue, SettingsService};
use crate::services::{AutoScoreService, ThemeService};
use crate::services::permission::PermissionLevel; use crate::services::permission::PermissionLevel;
use crate::state::AppState; use crate::state::AppState;
@@ -17,6 +21,25 @@ fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
Ok(()) Ok(())
} }
fn sqlite_db_path(app_handle: &AppHandle) -> Result<String, String> {
if cfg!(all(debug_assertions, desktop)) {
return Ok("data.sql".to_string());
}
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
let data_dir = app_data_dir.join("data");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("data.sql");
db_path
.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Invalid sqlite database path".to_string())
}
#[tauri::command] #[tauri::command]
pub async fn data_export_json( pub async fn data_export_json(
state: State<'_, Arc<RwLock<AppState>>>, state: State<'_, Arc<RwLock<AppState>>>,
@@ -60,3 +83,101 @@ pub async fn data_import_json(
)) ))
} }
} }
#[tauri::command]
pub async fn data_reset_all(
app_handle: AppHandle,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<()>, String> {
check_admin_permission(&state)?;
let sqlite_path = sqlite_db_path(&app_handle)?;
let sqlite_conn = create_sqlite_connection(&sqlite_path)
.await
.map_err(|e| format!("Failed to open sqlite database: {}", e))?;
let (active_conn, active_db_type) = {
let state_guard = state.read();
let active_conn = state_guard
.db
.read()
.clone()
.ok_or_else(|| "Database not connected".to_string())?;
let db_type = {
let settings = state_guard.settings.read();
match settings.get_value(SettingsKey::PgConnectionStatus) {
SettingsValue::Json(json) => match json.get("type").and_then(|value| value.as_str()) {
Some("postgresql") => DatabaseType::PostgreSQL,
_ => DatabaseType::SQLite,
},
_ => DatabaseType::SQLite,
}
};
(active_conn, db_type)
};
if active_db_type == DatabaseType::PostgreSQL {
Migration::reset_database(&active_conn, false)
.await
.map_err(|e| format!("Failed to reset PostgreSQL database: {}", e))?;
}
Migration::reset_database(&sqlite_conn, true)
.await
.map_err(|e| format!("Failed to reset SQLite database: {}", e))?;
{
let state_guard = state.read();
{
let mut db = state_guard.db.write();
*db = Some(sqlite_conn.clone());
}
{
let mut settings = state_guard.settings.write();
*settings = SettingsService::new();
settings.attach_db(Some(sqlite_conn.clone()));
settings
.initialize()
.await
.map_err(|e| format!("Failed to reinitialize settings: {}", e))?;
settings
.set_value(
SettingsKey::PgConnectionStatus,
SettingsValue::Json(serde_json::json!({
"connected": true,
"type": "sqlite"
})),
)
.await
.map_err(|e| format!("Failed to restore sqlite status: {}", e))?;
}
{
let mut permissions = state_guard.permissions.write();
permissions.clear_all_permissions();
permissions.update_password_status(false, false);
}
{
let mut theme = state_guard.theme.write();
*theme = ThemeService::new();
}
{
let mut auto_score = state_guard.auto_score.write();
*auto_score = AutoScoreService::new();
}
}
let _ = app_handle.emit(
"settings:changed",
serde_json::json!({
"key": "is_wizard_completed",
"value": false
}),
);
Ok(IpcResponse::success_empty())
}
+1
View File
@@ -90,6 +90,7 @@ pub fn run() {
log_write, log_write,
data_export_json, data_export_json,
data_import_json, data_import_json,
data_reset_all,
window_minimize, window_minimize,
window_maximize, window_maximize,
window_close, window_close,
+41 -19
View File
@@ -32,6 +32,27 @@ interface reasonItem {
delta: number delta: number
} }
const mergeStudents = (prev: studentItem[], names: string[]): studentItem[] => {
const existing = new Set(prev.map((item) => item.name))
const next = [...prev]
names.forEach((rawName) => {
const name = rawName.trim()
if (!name || existing.has(name)) return
next.push({ name })
existing.add(name)
})
return next
}
const mergeReasons = (prev: reasonItem[], nextReason: reasonItem): reasonItem[] => {
if (prev.some((item) => item.content === nextReason.content)) {
return prev
}
return [...prev, nextReason]
}
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
const withTimeout = async (promise: Promise<any>, ms: number, timeoutMessage: string): Promise<any> => { const withTimeout = async (promise: Promise<any>, ms: number, timeoutMessage: string): Promise<any> => {
@@ -215,25 +236,25 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
const existing = new Set(students.map((s) => s.name)) const existing = new Set(students.map((s) => s.name))
const added = new Set<string>() const added = new Set<string>()
const newStudents: studentItem[] = [] const newStudentNames: string[] = []
lines.forEach((name) => { lines.forEach((name) => {
if (existing.has(name) || added.has(name)) return if (existing.has(name) || added.has(name)) return
newStudents.push({ name }) newStudentNames.push(name)
added.add(name) added.add(name)
}) })
if (newStudents.length === 0) { if (newStudentNames.length === 0) {
showOobeMessage("warning", t("oobe.steps.students.studentExists")) showOobeMessage("warning", t("oobe.steps.students.studentExists"))
return return
} }
setStudents([...students, ...newStudents]) setStudents((prev) => mergeStudents(prev, newStudentNames))
setNewStudentName("") setNewStudentName("")
} }
const removeStudent = (name: string) => { const removeStudent = (name: string) => {
setStudents(students.filter((s) => s.name !== name)) setStudents((prev) => prev.filter((s) => s.name !== name))
} }
const addReason = () => { const addReason = () => {
@@ -243,13 +264,13 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
messageApi.warning(t("oobe.steps.reasons.reasonExists")) messageApi.warning(t("oobe.steps.reasons.reasonExists"))
return return
} }
setReasons([...reasons, { content, delta: newReasonDelta }]) setReasons((prev) => mergeReasons(prev, { content, delta: newReasonDelta }))
setNewReasonContent("") setNewReasonContent("")
setNewReasonDelta(1) setNewReasonDelta(1)
} }
const removeReason = (content: string) => { const removeReason = (content: string) => {
setReasons(reasons.filter((r) => r.content !== content)) setReasons((prev) => prev.filter((r) => r.content !== content))
} }
const handleFileImport = useCallback( const handleFileImport = useCallback(
@@ -269,15 +290,17 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
typeof item === "string" ? item : item.name || item.student_name typeof item === "string" ? item : item.name || item.student_name
) )
} }
const newStudents = studentList const newStudentNames = studentList
.filter((name) => name && typeof name === "string") .filter((name) => name && typeof name === "string")
.filter((name) => !students.some((s) => s.name === name)) .map((name) => name.trim())
.map((name) => ({ name: name.trim() })) .filter(Boolean)
if (newStudents.length > 0) { const mergedStudents = mergeStudents(students, newStudentNames)
setStudents([...students, ...newStudents]) const importedCount = mergedStudents.length - students.length
if (importedCount > 0) {
setStudents((prev) => mergeStudents(prev, newStudentNames))
showOobeMessage( showOobeMessage(
"success", "success",
t("oobe.steps.students.importSuccess", { count: newStudents.length }) t("oobe.steps.students.importSuccess", { count: importedCount })
) )
} else { } else {
showOobeMessage("info", t("oobe.steps.students.noNewStudents")) showOobeMessage("info", t("oobe.steps.students.noNewStudents"))
@@ -300,14 +323,13 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
names.push(name.trim()) names.push(name.trim())
} }
}) })
const newStudents = names const mergedStudents = mergeStudents(students, names)
.filter((name) => !students.some((s) => s.name === name)) const importedCount = mergedStudents.length - students.length
.map((name) => ({ name })) if (importedCount > 0) {
if (newStudents.length > 0) { setStudents((prev) => mergeStudents(prev, names))
setStudents([...students, ...newStudents])
showOobeMessage( showOobeMessage(
"success", "success",
t("oobe.steps.students.importSuccess", { count: newStudents.length }) t("oobe.steps.students.importSuccess", { count: importedCount })
) )
} else { } else {
showOobeMessage("info", t("oobe.steps.students.noNewStudents")) showOobeMessage("info", t("oobe.steps.students.noNewStudents"))
+62
View File
@@ -75,6 +75,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
const [clearDialogVisible, setClearDialogVisible] = useState(false) const [clearDialogVisible, setClearDialogVisible] = useState(false)
const [clearLoading, setClearLoading] = useState(false) const [clearLoading, setClearLoading] = useState(false)
const [resetDialogVisible, setResetDialogVisible] = useState(false)
const [resetLoading, setResetLoading] = useState(false)
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -339,6 +341,30 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
} }
} }
const openResetDialog = () => {
setResetDialogVisible(true)
}
const handleConfirmResetAll = async () => {
if (!(window as any).api?.dataResetAll) return
setResetLoading(true)
try {
const res = await (window as any).api.dataResetAll()
if (res.success) {
messageApi.success(t("settings.data.resetSuccess"))
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
window.setTimeout(() => window.location.reload(), 300)
} else {
messageApi.error(res.message || t("settings.data.resetFailed"))
}
} catch (e: any) {
messageApi.error(e?.message || t("settings.data.resetFailed"))
} finally {
setResetLoading(false)
setResetDialogVisible(false)
}
}
const confirmSettlement = () => { const confirmSettlement = () => {
setSettleDialogVisible(true) setSettleDialogVisible(true)
} }
@@ -953,6 +979,24 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
</Space> </Space>
</Card> </Card>
<Card
title={t("settings.data.quickSetupReset")}
style={{
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<Space align="center">
<Button danger disabled={!canAdmin} loading={resetLoading} onClick={openResetDialog}>
{t("settings.data.resetAllAndReenter")}
</Button>
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{t("settings.data.resetHint")}
</div>
</Space>
</Card>
<Card <Card
style={{ style={{
backgroundColor: "var(--ss-card-bg)", backgroundColor: "var(--ss-card-bg)",
@@ -1361,6 +1405,24 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
> >
</Modal> </Modal>
<Modal
title={t("settings.data.confirmResetAll")}
open={resetDialogVisible}
onCancel={() => !resetLoading && setResetDialogVisible(false)}
onOk={handleConfirmResetAll}
confirmLoading={resetLoading}
okText={t("settings.data.resetAllAndReenter")}
okButtonProps={{ danger: true }}
cancelText={t("common.cancel")}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div>{t("settings.data.resetConfirm1")}</div>
<div style={{ color: "var(--ss-text-secondary)", fontSize: "12px" }}>
{t("settings.data.resetConfirm2")}
</div>
</div>
</Modal>
</div> </div>
) )
} }
+12 -4
View File
@@ -37,17 +37,17 @@
"none": "None" "none": "None"
}, },
"oobe": { "oobe": {
"title": "Welcome to SecScore", "title": "SecScore Quick Setup",
"subtitle": "Education Points Management Expert", "subtitle": "Finish the essentials in a few steps and start using the app",
"step": "Step {{current}}/{{total}}", "step": "Step {{current}}/{{total}}",
"skip": "Skip", "skip": "Skip",
"steps": { "steps": {
"entry": { "entry": {
"title": "Get Started", "title": "Get Started",
"description": "Choose how you want to proceed", "description": "Choose how you want to proceed",
"enterOobe": "Enter OOBE", "enterOobe": "Enter Quick Setup",
"connectPostgresAutoSync": "Connect PostgreSQL with Auto Sync", "connectPostgresAutoSync": "Connect PostgreSQL with Auto Sync",
"skipDirect": "Skip OOBE and Enter" "skipDirect": "Skip Quick Setup and Enter"
}, },
"postgresql": { "postgresql": {
"title": "Connect PostgreSQL", "title": "Connect PostgreSQL",
@@ -233,6 +233,9 @@
"settlement": "Settlement", "settlement": "Settlement",
"settlementAndRestart": "Settle and Restart", "settlementAndRestart": "Settle and Restart",
"settlementHint": "Mark current records as a phase and reset all student points to zero.", "settlementHint": "Mark current records as a phase and reset all student points to zero.",
"quickSetupReset": "Quick Setup Reset",
"resetAllAndReenter": "Delete all data and re-enter Quick Setup",
"resetHint": "Delete students, reasons, records, rewards, tags, and all settings, then return to Quick Setup.",
"importExport": "Import / Export", "importExport": "Import / Export",
"exportJson": "Export JSON", "exportJson": "Export JSON",
"importJson": "Import JSON", "importJson": "Import JSON",
@@ -253,10 +256,15 @@
"exportSuccess": "Export successful", "exportSuccess": "Export successful",
"importSuccess": "Import successful, refreshing", "importSuccess": "Import successful, refreshing",
"importFailed": "Import failed", "importFailed": "Import failed",
"resetSuccess": "Reset complete, reopening Quick Setup",
"resetFailed": "Reset failed",
"clearFailed": "Clear failed", "clearFailed": "Clear failed",
"confirmSettlement": "Confirm settlement and restart?", "confirmSettlement": "Confirm settlement and restart?",
"settlementConfirm1": "This will archive current unsettled records as a phase and reset all student points to zero.", "settlementConfirm1": "This will archive current unsettled records as a phase and reset all student points to zero.",
"settlementConfirm2": "Student list remains unchanged; settlement history available in 'Settlements' page.", "settlementConfirm2": "Student list remains unchanged; settlement history available in 'Settlements' page.",
"confirmResetAll": "Delete all data and re-enter Quick Setup?",
"resetConfirm1": "This will delete students, reasons, score records, rewards, tags, and all settings. This cannot be undone.",
"resetConfirm2": "After reset, the app will reload and open Quick Setup directly.",
"settlementSuccess": "Settlement successful, points restarted", "settlementSuccess": "Settlement successful, points restarted",
"settlementFailed": "Settlement failed", "settlementFailed": "Settlement failed",
"settle": "Settle", "settle": "Settle",
+12 -4
View File
@@ -37,17 +37,17 @@
"none": "无" "none": "无"
}, },
"oobe": { "oobe": {
"title": "欢迎使用 SecScore", "title": "SecScore 快速设置",
"subtitle": "教育积分管理专家", "subtitle": "几步完成基础配置,马上开始使用",
"step": "第 {{current}}/{{total}} 步", "step": "第 {{current}}/{{total}} 步",
"skip": "跳过", "skip": "跳过",
"steps": { "steps": {
"entry": { "entry": {
"title": "开始配置", "title": "开始配置",
"description": "请选择接下来要进行的操作", "description": "请选择接下来要进行的操作",
"enterOobe": "进入OOBE", "enterOobe": "进入快速设置",
"connectPostgresAutoSync": "连接PostgreSQL自动同步", "connectPostgresAutoSync": "连接PostgreSQL自动同步",
"skipDirect": "跳过OOBE,直接进入" "skipDirect": "跳过快速设置,直接进入"
}, },
"postgresql": { "postgresql": {
"title": "连接 PostgreSQL", "title": "连接 PostgreSQL",
@@ -233,6 +233,9 @@
"settlement": "结算", "settlement": "结算",
"settlementAndRestart": "结算并重新开始", "settlementAndRestart": "结算并重新开始",
"settlementHint": "将当前未结算记录划分为一个阶段,并将所有学生积分清零。", "settlementHint": "将当前未结算记录划分为一个阶段,并将所有学生积分清零。",
"quickSetupReset": "快速设置重置",
"resetAllAndReenter": "删除所有数据并重新进入快速设置",
"resetHint": "删除学生、理由、记录、奖励、标签和全部设置,然后重新进入快速设置。",
"importExport": "导入 / 导出", "importExport": "导入 / 导出",
"exportJson": "导出 JSON", "exportJson": "导出 JSON",
"importJson": "导入 JSON", "importJson": "导入 JSON",
@@ -253,10 +256,15 @@
"exportSuccess": "导出成功", "exportSuccess": "导出成功",
"importSuccess": "导入成功,正在刷新", "importSuccess": "导入成功,正在刷新",
"importFailed": "导入失败", "importFailed": "导入失败",
"resetSuccess": "已重置,正在重新进入快速设置",
"resetFailed": "重置失败",
"clearFailed": "清空失败", "clearFailed": "清空失败",
"confirmSettlement": "确认结算并重新开始?", "confirmSettlement": "确认结算并重新开始?",
"settlementConfirm1": "将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。", "settlementConfirm1": "将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。",
"settlementConfirm2": "学生名单不变;结算后的历史在「结算历史」页面查看。", "settlementConfirm2": "学生名单不变;结算后的历史在「结算历史」页面查看。",
"confirmResetAll": "确认删除所有数据并重新进入快速设置?",
"resetConfirm1": "这会删除学生、理由、积分记录、奖励、标签以及全部设置,且不可恢复。",
"resetConfirm2": "重置后应用将重新加载,并直接进入快速设置。",
"settlementSuccess": "结算成功,已重新开始积分", "settlementSuccess": "结算成功,已重新开始积分",
"settlementFailed": "结算失败", "settlementFailed": "结算失败",
"settle": "结算", "settle": "结算",
+1
View File
@@ -205,6 +205,7 @@ const api = {
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"), exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
importDataJson: (jsonText: string): Promise<{ success: boolean }> => importDataJson: (jsonText: string): Promise<{ success: boolean }> =>
invoke("data_import_json", { jsonText }), invoke("data_import_json", { jsonText }),
dataResetAll: (): Promise<{ success: boolean }> => invoke("data_reset_all"),
// Window // Window
windowMinimize: (): Promise<void> => invoke("window_minimize"), windowMinimize: (): Promise<void> => invoke("window_minimize"),