feat: 完整日志系统 + 设置增强 + 子窗口交互优化

This commit is contained in:
2026-07-20 13:17:28 +08:00
parent 1bc2da9c20
commit f89391c791
22 changed files with 559 additions and 112 deletions
+1
View File
@@ -4,3 +4,4 @@ src-tauri/target/
*.db
.DS_Store
*.log
.env
+1 -1
View File
@@ -13,9 +13,9 @@
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-global-shortcut": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.1",
"vue": "^3.5.39",
+7 -7
View File
@@ -63,8 +63,7 @@ pub async fn call_ai_api(
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
]}
],
"max_tokens": 4096,
"temperature": 0.3
"max_tokens": 4096
})
} else {
serde_json::json!({
@@ -76,8 +75,7 @@ pub async fn call_ai_api(
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
]}
],
"max_tokens": 4096,
"temperature": 0.3
"max_tokens": 4096
})
};
@@ -103,8 +101,10 @@ pub async fn call_ai_api(
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
if error_text.contains("does not support image") || error_text.contains("does not support") && error_text.contains("image") {
if error_text.contains("does not support image") || error_text.contains("does not support") && error_text.contains("image") || error_text.contains("Cannot read") || error_text.contains("image.png") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
} else if status.as_u16() == 401 || error_text.contains("invalid") || error_text.contains("Invalid") || error_text.contains("unauthorized") || error_text.contains("Unauthorized") || error_text.contains("Token") || error_text.contains("API key") {
last_error = "API 认证失败,请检查 API Key 是否正确".to_string();
} else {
last_error = format!("API 返回错误 ({}): {}", status, &error_text);
}
@@ -117,14 +117,14 @@ pub async fn call_ai_api(
}
let body = response.text().await.map_err(|e| e.to_string())?;
if body.contains("does not support image") || body.contains("does not support") && body.contains("image") {
if body.contains("does not support image") || body.contains("does not support") && body.contains("image") || body.contains("Cannot read") || body.contains("image.png") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
log::warn!("AI API body error: {}", last_error);
return Err(last_error);
}
let json: Value = serde_json::from_str(&body).map_err(|e| format!("解析返回JSON失败: {}", e))?;
if let Some(err_msg) = json["error"]["message"].as_str() {
if err_msg.contains("does not support image") || err_msg.contains("image input") {
if err_msg.contains("does not support image") || err_msg.contains("image input") || err_msg.contains("Cannot read") || err_msg.contains("image.png") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
return Err(last_error);
}
+22
View File
@@ -0,0 +1,22 @@
use crate::AppState;
use tauri::State;
#[tauri::command]
pub fn log_write(state: State<AppState>, level: String, message: String, source: String) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.write_log(&level, &message, &source);
Ok(())
}
#[tauri::command]
pub fn log_list(state: State<AppState>, limit: Option<i64>) -> Result<Vec<serde_json::Value>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.list_logs(limit.unwrap_or(200)))
}
#[tauri::command]
pub fn log_clear(state: State<AppState>) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.clear_logs();
Ok(())
}
+50 -1
View File
@@ -4,6 +4,7 @@ pub mod history;
pub mod grading;
pub mod rubric;
pub mod template;
pub mod log;
use crate::ai;
use crate::create_dialog_window;
@@ -22,12 +23,13 @@ pub struct ImageResult {
#[tauri::command]
pub async fn dialog_open(app: AppHandle, dialog_type: String) -> Result<(), String> {
let sizes: [(&str, f64, f64); 5] = [
let sizes: [(&str, f64, f64); 6] = [
("settings", 520.0, 540.0),
("history", 860.0, 660.0),
("templates", 720.0, 540.0),
("rubric", 650.0, 700.0),
("result", 620.0, 680.0),
("logs", 860.0, 660.0),
];
let (_, w, h) = sizes.iter().find(|(t, _, _)| *t == dialog_type).unwrap_or(&("result", 600.0, 780.0));
create_dialog_window(&app, &dialog_type, *w, *h).map_err(|e| e.to_string())
@@ -87,3 +89,50 @@ pub async fn ai_grade(app: AppHandle, request: GradeRequest) -> Result<ai::Grade
)
.await
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchModelsRequest {
pub api_key: String,
pub base_url: String,
}
#[tauri::command]
pub async fn ai_fetch_models(request: FetchModelsRequest) -> Result<Vec<String>, String> {
let url = format!("{}/models", request.base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", request.api_key))
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("API 返回错误 ({}): {}", status, text));
}
let json: serde_json::Value = resp.json().await
.map_err(|e| format!("解析返回数据失败: {}", e))?;
let models = json["data"]
.as_array()
.ok_or_else(|| "返回数据格式异常:缺少 data 字段".to_string())?;
let ids: Vec<String> = models
.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect();
if ids.is_empty() {
return Err("未获取到任何模型".to_string());
}
Ok(ids)
}
+4
View File
@@ -6,6 +6,7 @@ use tauri_plugin_store::StoreExt;
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub api_key: String,
pub api_key_presets: std::collections::HashMap<String, String>,
pub base_url: String,
pub model: String,
pub shortcut: String,
@@ -20,6 +21,7 @@ impl Default for AppSettings {
fn default() -> Self {
AppSettings {
api_key: String::new(),
api_key_presets: std::collections::HashMap::new(),
base_url: "https://api.siliconflow.cn/v1".to_string(),
model: String::new(),
shortcut: "Alt+Q".to_string(),
@@ -37,6 +39,7 @@ pub async fn settings_get(app: AppHandle) -> Result<AppSettings, String> {
let store = app.store("settings.json").map_err(|e| e.to_string())?;
Ok(AppSettings {
api_key: store.get("apiKey").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
api_key_presets: store.get("apiKeyPresets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(),
base_url: store.get("baseUrl").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "https://api.siliconflow.cn/v1".to_string()),
model: store.get("model").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
shortcut: store.get("shortcut").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "Alt+Q".to_string()),
@@ -55,6 +58,7 @@ pub async fn settings_get(app: AppHandle) -> Result<AppSettings, String> {
pub async fn settings_set(app: AppHandle, settings: AppSettings) -> Result<bool, String> {
let store = app.store("settings.json").map_err(|e| e.to_string())?;
store.set("apiKey", serde_json::Value::String(settings.api_key));
store.set("apiKeyPresets", serde_json::to_value(&settings.api_key_presets).unwrap_or(serde_json::Value::Object(Default::default())));
store.set("baseUrl", serde_json::Value::String(settings.base_url));
store.set("model", serde_json::Value::String(settings.model));
store.set("shortcut", serde_json::Value::String(settings.shortcut));
+36 -1
View File
@@ -48,6 +48,13 @@ impl Database {
rubric TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
reference_answer_image TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL DEFAULT 'INFO',
message TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);"
).expect("Failed to create tables");
Database { conn }
@@ -159,7 +166,7 @@ impl Database {
let q_max: f64 = row.get(2)?;
let q_ref: String = row.get(3)?;
let q_rubric: String = row.get(4)?;
let q_img: String = row.get(5)?;
let q_img: String = row.get(6)?;
let ref_img = if q_img.is_empty() || q_img.starts_with("data:") { q_img } else { format!("data:image/png;base64,{}", q_img) };
Ok(serde_json::json!({
"questionTitle": q_title,
@@ -237,4 +244,32 @@ impl Database {
pub fn delete_template(&self, id: i64) -> bool {
self.conn.execute("DELETE FROM exam WHERE id = ?", params![id]).is_ok()
}
// Log methods
pub fn write_log(&self, level: &str, message: &str, source: &str) {
let _ = self.conn.execute(
"INSERT INTO logs (level, message, source) VALUES (?1, ?2, ?3)",
params![level, message, source],
);
}
pub fn list_logs(&self, limit: i64) -> Vec<serde_json::Value> {
let mut stmt = self.conn.prepare(
"SELECT * FROM logs ORDER BY id DESC LIMIT ?"
).unwrap();
let rows = stmt.query_map(params![limit], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"level": row.get::<_, String>(1)?,
"message": row.get::<_, String>(2)?,
"source": row.get::<_, String>(3)?,
"createdAt": row.get::<_, String>(4)?
}))
}).unwrap();
rows.filter_map(|r| r.ok()).collect()
}
pub fn clear_logs(&self) {
let _ = self.conn.execute("DELETE FROM logs", []);
}
}
+7 -1
View File
@@ -96,6 +96,7 @@ pub fn run() {
commands::screenshot::screenshot_capture,
commands::screenshot::screenshot_cancel,
commands::ai_grade,
commands::ai_fetch_models,
commands::history::history_list,
commands::history::history_delete,
commands::history::history_get_by_id,
@@ -117,6 +118,9 @@ pub fn run() {
commands::template::template_import_json,
commands::dialog_open,
commands::dialog_open_image,
commands::log::log_write,
commands::log::log_list,
commands::log::log_clear,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -165,14 +169,16 @@ pub fn create_dialog_window(
"settings" => "设置",
"history" => "历史记录",
"templates" => "模板管理",
"logs" => "日志",
"rubric" => "评分标准",
"result" => "评分结果",
_ => "AI 阅卷助手",
})
.inner_size(width, height)
.min_inner_size(400.0, 300.0)
.position(dx, dy)
.decorations(false)
.resizable(false)
.resizable(true)
.build()?;
Ok(())
}
+5 -17
View File
@@ -3,6 +3,7 @@
<SettingDialog v-if="dialogType === 'settings'" />
<HistoryDialog v-if="dialogType === 'history'" />
<TemplateDialog v-if="dialogType === 'templates'" />
<LogDialog v-if="dialogType === 'logs'" />
<RubricEditor v-if="dialogType === 'rubric'" />
<ResultDrawer v-if="dialogType === 'result'" />
</template>
@@ -12,15 +13,16 @@
</template>
<script setup lang="ts">
import { nextTick, onMounted } from 'vue'
import { onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useSettingsStore } from '@/stores/settings'
import { useGradingStore } from '@/stores/grading'
import { LogicalSize, getCurrentWindow } from '@tauri-apps/api/window'
import ResultDrawer from '@/components/ResultDrawer.vue'
import logger from '@/utils/logger'
import SettingDialog from '@/components/SettingDialog.vue'
import HistoryDialog from '@/pages/HistoryDialog.vue'
import TemplateDialog from '@/pages/TemplateDialog.vue'
import LogDialog from '@/pages/LogDialog.vue'
import RubricEditor from '@/components/RubricEditor.vue'
const appStore = useAppStore()
@@ -33,19 +35,6 @@ const isScreenshot = window.location.hash.startsWith('#/screenshot')
const winClass = isScreenshot ? 'win-screenshot' : dialogType ? 'win-dialog' : 'win-main'
document.documentElement.dataset.window = winClass
async function resizeToContent() {
await nextTick()
await nextTick()
const el = document.querySelector('.el-dialog') || document.querySelector('.drawer-panel') || document.querySelector('.win-dialog-body')
if (!el) return
const rect = el.getBoundingClientRect()
try {
const w = Math.ceil(rect.width) + 40
const h = Math.ceil(rect.height) + 80
await getCurrentWindow().setSize(new LogicalSize(Math.max(300, w), Math.max(200, h)))
} catch {}
}
onMounted(async () => {
if (dialogType === 'result') {
const { invoke } = await import('@tauri-apps/api/core')
@@ -57,17 +46,16 @@ onMounted(async () => {
if (data.currentQuestionIndex !== undefined) gradingStore.setCurrentQuestionIndex(data.currentQuestionIndex)
}
appStore.openResultDrawer()
await resizeToContent()
} else if (dialogType) {
if (dialogType === 'settings') await settingsStore.loadSettings()
const actions: Record<string, () => void> = {
settings: () => appStore.openSettingDialog(),
history: () => appStore.openHistoryDialog(),
templates: () => appStore.openTemplateDialog(),
logs: () => appStore.openLogDialog(),
rubric: () => appStore.openRubricEditor()
}
actions[dialogType]?.()
setTimeout(resizeToContent, 200)
} else {
await settingsStore.loadSettings()
}
+52 -8
View File
@@ -1,5 +1,5 @@
<template>
<div ref="toolbarRef" class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }" @mousedown="onDragStart">
<div ref="toolbarRef" class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible, 'has-notification': !!notification }" @mousedown="onDragStart">
<div class="toolbar-buttons">
<el-button class="toolbar-btn" @click="startScreenshot" :loading="appStore.isGrading">
<span class="btn-icon">📷</span>
@@ -28,6 +28,10 @@
<span class="btn-icon">📜</span>
<span class="btn-label">历史</span>
</el-button>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openLogs()">
<span class="btn-icon">📋</span>
<span class="btn-label">日志</span>
</el-button>
<el-button class="toolbar-btn collapse-btn" @click="showMenu = false">
<span class="btn-icon"></span>
<span class="btn-label">收起</span>
@@ -39,6 +43,7 @@
</el-button>
</div>
<div class="status-bar">{{ statusText }}</div>
<div v-if="notification" :class="['notification-bar', `notification-${notificationType}`]">{{ notification }}</div>
</div>
</template>
@@ -50,6 +55,7 @@ import { useGradingStore } from '@/stores/grading'
import { useSettingsStore } from '@/stores/settings'
import { addWatermark } from '@/utils/image'
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'
import logger from '@/utils/logger'
const appStore = useAppStore()
const gradingStore = useGradingStore()
@@ -57,6 +63,16 @@ const settingsStore = useSettingsStore()
const showMenu = ref(false)
const toolbarRef = ref<HTMLElement | null>(null)
const notification = ref('')
const notificationType = ref<'success' | 'error' | 'warning'>('success')
let notificationTimer: ReturnType<typeof setTimeout> | null = null
function showNotification(msg: string, type: 'success' | 'error' | 'warning' = 'success', duration = 4000) {
notification.value = msg
notificationType.value = type
if (notificationTimer) clearTimeout(notificationTimer)
notificationTimer = setTimeout(() => { notification.value = '' }, duration)
}
const statusText = computed(() => {
const questions = gradingStore.questions
@@ -69,11 +85,13 @@ async function resizeWindow() {
if (toolbarRef.value) {
try {
const w = toolbarRef.value.scrollWidth + 2
await getCurrentWindow().setSize(new LogicalSize(Math.max(200, Math.min(w, 1200)), 64))
const h = toolbarRef.value.scrollHeight + 2
await getCurrentWindow().setSize(new LogicalSize(Math.max(200, Math.min(w, 1200)), h))
} catch {}
}
}
watch(showMenu, resizeWindow)
watch(notification, resizeWindow)
let unlistenScreenshot: (() => void) | null = null
let unlistenRubric: (() => void) | null = null
@@ -88,7 +106,9 @@ onMounted(async () => {
gradingStore.setImage(await addWatermark(event.payload, '学生答案'))
await startGrading()
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'screenshot-completed')
ElMessage.error('水印处理失败:' + msg)
}
}
})
@@ -100,7 +120,7 @@ onMounted(async () => {
ElMessage.success(`已应用模板(${data.questions.length} 题)`)
}
})
} catch {}
} catch (e) { logger.error('listen rubric-updated failed', 'FloatingToolbar', e) }
})
onUnmounted(() => {
@@ -113,7 +133,9 @@ async function startScreenshot() {
try {
await invoke('screenshot_start')
} catch (err) {
ElMessage.error('截图失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'startScreenshot')
ElMessage.error('截图失败:' + msg)
}
}
@@ -122,7 +144,9 @@ async function selectImage() {
const data: any = await invoke('dialog_open_image')
if (data) {
try { gradingStore.setImage(await addWatermark(data.base64, '学生答案')) } catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message); return
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'selectImage')
ElMessage.error('水印处理失败:' + msg); return
}
await startGrading()
}
@@ -152,6 +176,7 @@ async function gradeQuestion(questionIdx: number) {
}
async function startGrading() {
await settingsStore.loadSettings()
if (!gradingStore.currentImage) { ElMessage.info('请先截图或选择图片'); return }
if (!settingsStore.settings.apiKey) {
ElMessage.warning('请先在设置中配置 API Key')
@@ -172,12 +197,24 @@ async function startGrading() {
const result = await gradeQuestion(nextIdx)
if (!result) return
gradingStore.setResult(result)
showNotification(`得分:${result.score} 分(${(result.confidence * 100).toFixed(0)}% 置信度)`, 'success')
await invoke('grading_store_data', {
data: { result, image: gradingStore.currentImage, questions, currentQuestionIndex: nextIdx }
})
await invoke('dialog_open', { dialogType: 'result' })
} catch (err) { ElMessage.error('AI 阅卷失败:' + (err as Error).message) }
finally { appStore.isGrading = false }
gradingStore.reset()
} catch (err) {
let msg = String((err as any)?.message ?? err)
msg = msg.replace(/Error invoking '\w+':\s*/i, '')
if (/does not support image|not support.*image|image.*input|Cannot read|image\.png/i.test(msg)) {
msg = '当前模型不支持图片输入,请在设置中更换为支持视觉的模型'
} else if (/does not support|model.*capable|image.*not/i.test(msg)) {
msg = '当前模型不支持图片输入,请在设置中更换为支持视觉的模型'
}
logger.error(msg, 'startGrading')
ElMessage.error(msg)
showNotification(msg, 'error', 6000)
} finally { appStore.isGrading = false }
}
function openRubric() {
@@ -190,6 +227,9 @@ async function openTemplates() {
function openHistory() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'history' }))
}
function openLogs() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'logs' }))
}
function openSettings() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'settings' }))
}
@@ -213,4 +253,8 @@ function onDragStart(e: MouseEvent) {
.btn-icon { font-size: 16px; line-height: 1; }
.btn-label { font-size: 10px; line-height: 1; white-space: nowrap; }
.is-hidden { opacity: 0; pointer-events: none; }
.notification-bar { padding: 4px 14px 8px; font-size: 12px; font-weight: 600; white-space: nowrap; text-align: center; border-radius: 0 0 12px 12px; }
.notification-success { color: #67c23a; background: rgba(103,194,58,0.1); }
.notification-error { color: #f56c6c; background: rgba(245,108,108,0.1); }
.notification-warning { color: #e6a23c; background: rgba(230,162,60,0.1); }
</style>
+31 -28
View File
@@ -1,7 +1,7 @@
<template>
<div class="result-drawer" v-show="visible" @click.self="closeDrawer">
<div class="drawer-panel">
<div class="drawer-header">
<div class="drawer-header" data-tauri-drag-region>
<span class="drawer-title">AI 评分结果</span>
<el-button text size="small" @click="closeDrawer"></el-button>
</div>
@@ -89,6 +89,7 @@ import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import { useSettingsStore } from '@/stores/settings'
import type { QuestionItem } from '@/types'
import logger from '@/utils/logger'
const appStore = useAppStore()
const gradingStore = useGradingStore()
@@ -115,22 +116,12 @@ watch(() => gradingStore.currentResult, (val) => {
}
})
watch(visible, async (val) => {
watch(visible, (val) => {
if (val) {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('grading_get_data')
if (data) {
gradingStore.setImage(data.image || '')
if (data.questions) {
questions.value = JSON.parse(JSON.stringify(data.questions))
gradingStore.setQuestions(data.questions)
}
currentIdx.value = data.currentQuestionIndex ?? 0
gradingStore.setCurrentQuestionIndex(currentIdx.value)
if (data.result) {
gradingStore.setResult(data.result)
}
if (gradingStore.questions.length) {
questions.value = JSON.parse(JSON.stringify(gradingStore.questions))
}
currentIdx.value = gradingStore.currentQuestionIndex
}
})
@@ -169,7 +160,15 @@ async function gradeAt(idx: number) {
})
ElMessage.success(`${idx + 1} 题评分完成`)
} catch (err) {
errorMsg.value = (err as Error).message
let msg = String((err as any)?.message ?? err)
msg = msg.replace(/Error invoking '\w+':\s*/i, '')
if (/does not support image|not support.*image|image.*input|Cannot read|image\.png/i.test(msg)) {
msg = '当前模型不支持图片输入,请在设置中更换为支持视觉的模型'
} else if (/does not support|model.*capable|image.*not/i.test(msg)) {
msg = '当前模型不支持图片输入,请在设置中更换为支持视觉的模型'
}
logger.error(msg, 'gradeAt')
errorMsg.value = msg
} finally {
isGrading.value = false
}
@@ -186,33 +185,35 @@ async function prevQuestion() {
}
async function saveAndAdvance(targetIdx: number) {
const { invoke } = await import('@tauri-apps/api/core')
const result = gradingStore.currentResult
if (!result) return
try {
const result = gradingStore.currentResult
const { invoke } = await import('@tauri-apps/api/core')
const raw = {
questionId: 0,
imagePath: '',
aiScore: result?.score ?? 0,
aiScore: result.score,
teacherScore: teacherScore.value,
confidence: result?.confidence ?? 0,
confidence: result.confidence,
studentAnswer: '',
questionTitle: currentQuestion.value.questionTitle,
maxScore: currentQuestion.value.maxScore,
referenceAnswer: currentQuestion.value.referenceAnswer,
rubric: currentQuestion.value.rubric,
deductions: result?.deductions,
comment: result?.comment,
deductions: result.deductions,
comment: result.comment,
resultJson: JSON.stringify(result),
imageData: gradingStore.currentImage || '',
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
}
await invoke('grading_submit', { data: JSON.parse(JSON.stringify(raw)) })
await invoke('rubric_set_next_index', { idx: targetIdx })
gradingStore.reset()
closeDrawer()
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'saveAndAdvance')
ElMessage.error('保存失败:' + msg)
}
closeDrawer()
}
async function reGrade() {
@@ -249,14 +250,16 @@ async function confirmScore() {
gradingStore.reset()
closeDrawer()
} catch (err) {
errorMsg.value = '保存失败:' + (err as Error).message
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'confirmScore')
errorMsg.value = '保存失败:' + msg
}
}
</script>
<style scoped>
.result-drawer { position: fixed; inset: 0; z-index: 99998; background: transparent; display: flex; justify-content: flex-end; }
.drawer-panel { width: 480px; height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); }
.result-drawer { position: fixed; inset: 0; z-index: 99998; background: #fff; display: flex; }
.drawer-panel { width: 100%; height: 100%; background: #fff; display: flex; flex-direction: column; }
.drawer-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #eee; }
.drawer-title { font-size: 16px; font-weight: 600; }
.drawer-body { flex: 1; overflow-y: auto; padding: 16px; }
+13 -4
View File
@@ -85,6 +85,7 @@ import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import type { QuestionItem } from '@/types'
import { addWatermark } from '@/utils/image'
import logger from '@/utils/logger'
const appStore = useAppStore()
const gradingStore = useGradingStore()
@@ -139,7 +140,9 @@ async function handleSave() {
await invoke('rubric_reset_next_index')
ElMessage.success('评分标准已保存')
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'RubricEditor.handleSave')
ElMessage.error('保存失败:' + msg)
}
}
@@ -149,7 +152,9 @@ async function handleExport() {
await invoke('rubric_export_json')
ElMessage.success('导出成功')
} catch (err) {
ElMessage.error('导出失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'RubricEditor.handleExport')
ElMessage.error('导出失败:' + msg)
}
}
@@ -163,7 +168,9 @@ async function handleImport() {
ElMessage.success(`已导入 ${data.questions.length}`)
}
} catch (err) {
ElMessage.error('导入失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'RubricEditor.handleImport')
ElMessage.error('导入失败:' + msg)
}
}
@@ -188,7 +195,9 @@ async function saveAsTemplate() {
showTemplateForm.value = false
}
} catch (err) {
ElMessage.error('保存模板失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'RubricEditor.saveAsTemplate')
ElMessage.error('保存模板失败:' + msg)
}
}
+90 -12
View File
@@ -9,18 +9,36 @@
<div class="settings-body">
<el-form :model="form" label-width="100px" size="small">
<el-form-item label="API Key">
<el-input v-model="form.apiKey" type="password" show-password placeholder="请输入 API Key" />
<div style="display:flex;gap:8px;width:100%;flex-wrap:wrap">
<el-select v-model="activePreset" placeholder="选择预设" size="small" style="width:120px" @change="applyPreset" clearable>
<el-option v-for="(key, name) in form.apiKeyPresets" :key="name" :label="name" :value="name" />
</el-select>
<el-input v-model="form.apiKey" type="password" show-password placeholder="输入或选择预设 API Key" style="flex:1;min-width:150px" />
<el-button size="small" @click="savePreset" :disabled="!form.apiKey">保存预设</el-button>
<el-button size="small" @click="deletePreset" :disabled="!activePreset" type="danger">删除</el-button>
</div>
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" />
<div style="display:flex;gap:8px;width:100%">
<el-select v-model="form.baseUrl" filterable allow-create default-first-option placeholder="选择或输入 API 地址" style="flex:1">
<el-option-group label="预设服务商">
<el-option v-for="p in presetProviders" :key="p.url" :label="p.label" :value="p.url" />
</el-option-group>
</el-select>
</div>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="form.model" placeholder="手动输入模型名称" />
<div style="display:flex;gap:8px;width:100%">
<el-select v-model="form.model" filterable allow-create default-first-option placeholder="输入或选择模型" style="flex:1">
<el-option v-for="m in fetchedModels" :key="m" :label="m" :value="m" />
</el-select>
<el-button size="small" @click="fetchModels" :loading="fetchingModels">获取模型</el-button>
</div>
<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px;">
<el-tag
v-for="m in visionModels"
v-for="m in popularModels"
:key="m"
size="small"
style="cursor:pointer"
@@ -70,11 +88,27 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useSettingsStore } from '@/stores/settings'
import logger from '@/utils/logger'
const visionModels = [
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const visible = ref(false)
const fetchedModels = ref<string[]>([])
const fetchingModels = ref(false)
const activePreset = ref('')
const presetProviders = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
{ label: 'Kimi (Moonshot)', url: 'https://api.moonshot.cn/v1' },
{ label: 'SiliconFlow', url: 'https://api.siliconflow.cn/v1' },
{ label: 'Qwen (DashScope)', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
]
const popularModels = [
'Qwen/Qwen2.5-32B-Instruct',
'Qwen/Qwen3.6-35B-A3B',
'gpt-4o',
@@ -83,11 +117,6 @@ const visionModels = [
'deepseek-vl2'
]
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const visible = ref(false)
watch(() => appStore.isSettingDialogOpen, (val) => {
visible.value = val
if (val) {
@@ -97,6 +126,53 @@ watch(() => appStore.isSettingDialogOpen, (val) => {
const form = ref({ ...settingsStore.settings })
async function fetchModels() {
if (!form.value.baseUrl || !form.value.apiKey) {
ElMessage.warning('请先填写 API Key 和 Base URL')
return
}
fetchingModels.value = true
try {
const { invoke } = await import('@tauri-apps/api/core')
const models: string[] = await invoke('ai_fetch_models', {
request: { apiKey: form.value.apiKey, baseUrl: form.value.baseUrl }
})
fetchedModels.value = models
if (models.length > 0) {
ElMessage.success(`获取到 ${models.length} 个模型`)
}
} catch (e) {
const msg = String((e as any)?.message ?? e)
ElMessage.error('获取模型列表失败:' + msg)
} finally {
fetchingModels.value = false
}
}
function applyPreset(name: string) {
if (name && form.value.apiKeyPresets[name]) {
form.value.apiKey = form.value.apiKeyPresets[name]
}
}
async function savePreset() {
try {
const { value } = await ElMessageBox.prompt('请输入预设名称', '保存 API Key', { inputPattern: /\S+/, inputErrorMessage: '名称不能为空' })
form.value.apiKeyPresets = { ...form.value.apiKeyPresets, [value]: form.value.apiKey }
activePreset.value = value
ElMessage.success(`预设 "${value}" 已保存`)
} catch { /* cancelled */ }
}
function deletePreset() {
if (!activePreset.value) return
const name = activePreset.value
const { [name]: _, ...rest } = form.value.apiKeyPresets
form.value.apiKeyPresets = rest
activePreset.value = ''
ElMessage.success(`预设 "${name}" 已删除`)
}
function handleClose() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
@@ -113,7 +189,9 @@ async function handleSave() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'SettingDialog')
ElMessage.error('保存失败:' + msg)
}
}
</script>
+12 -3
View File
@@ -1,5 +1,10 @@
<template>
<el-dialog v-model="visible" title="历史记录" width="800px" fullscreen :modal="false" @close="handleClose">
<el-dialog v-model="visible" fullscreen :modal="false" @close="handleClose">
<template #header>
<div class="dialog-header" data-tauri-drag-region>
<span class="dialog-title">历史记录</span>
</div>
</template>
<el-table :data="records" style="width: 100%" size="small" v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="ai_score" label="AI分" width="70" />
@@ -96,6 +101,7 @@ import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStore } from '@/stores/app'
import type { HistoryRecord } from '@/types'
import logger from '@/utils/logger'
const appStore = useAppStore()
const visible = ref(false)
@@ -119,7 +125,8 @@ async function refresh() {
try {
const { invoke } = await import('@tauri-apps/api/core')
records.value = await invoke('history_list')
} catch {
} catch (e) {
logger.error('refresh failed', 'HistoryDialog', e)
records.value = []
} finally {
loading.value = false
@@ -137,7 +144,9 @@ async function viewResult(row: HistoryRecord) {
ElMessage.warning('未找到记录详情')
}
} catch (err) {
ElMessage.error('加载详情失败:' + (err as Error).message)
const msg = String((err as any)?.message ?? err)
logger.error(msg, 'HistoryDialog.loadDetail')
ElMessage.error('加载详情失败:' + msg)
}
}
+109
View File
@@ -0,0 +1,109 @@
<template>
<el-dialog v-model="visible" fullscreen :modal="false" @close="handleClose">
<template #header>
<div class="dialog-header" data-tauri-drag-region>
<span class="dialog-title">日志</span>
</div>
</template>
<div class="toolbar">
<el-select v-model="levelFilter" placeholder="全部级别" size="small" style="width: 120px" clearable>
<el-option label="全部" value="" />
<el-option label="DEBUG" value="DEBUG" />
<el-option label="INFO" value="INFO" />
<el-option label="WARN" value="WARN" />
<el-option label="ERROR" value="ERROR" />
</el-select>
<el-button size="small" @click="refresh">刷新</el-button>
<el-button size="small" type="danger" @click="clearLogs">清空</el-button>
</div>
<el-table :data="filteredLogs" style="width: 100%" size="small" v-loading="loading" max-height="calc(100vh - 160px)">
<el-table-column prop="id" label="ID" width="55" />
<el-table-column prop="level" label="级别" width="70">
<template #default="{ row }">
<el-tag :type="levelType(row.level)" size="small" effect="dark" style="width: 100%; text-align: center">
{{ row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" min-width="300" show-overflow-tooltip />
<el-table-column prop="source" label="来源" width="160" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="155" />
</el-table>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStore } from '@/stores/app'
import logger from '@/utils/logger'
interface LogEntry {
id: number
level: string
message: string
source: string
created_at: string
}
const appStore = useAppStore()
const visible = ref(false)
const loading = ref(false)
const logs = ref<LogEntry[]>([])
const levelFilter = ref('')
const filteredLogs = computed(() => {
if (!levelFilter.value) return logs.value
return logs.value.filter(l => l.level === levelFilter.value)
})
function levelType(level: string) {
switch (level) {
case 'ERROR': return 'danger'
case 'WARN': return 'warning'
case 'INFO': return 'primary'
default: return 'info'
}
}
watch(() => appStore.isLogDialogOpen, (val) => {
visible.value = val
if (val) refresh()
})
async function refresh() {
loading.value = true
try {
const { invoke } = await import('@tauri-apps/api/core')
logs.value = await invoke('log_list', { limit: 500 })
} catch (e) {
logger.error('refresh failed', 'LogDialog', e)
logs.value = []
} finally {
loading.value = false
}
}
async function clearLogs() {
try {
await ElMessageBox.confirm('确定清空所有日志?', '提示')
const { invoke } = await import('@tauri-apps/api/core')
await invoke('log_clear')
ElMessage.success('日志已清空')
refresh()
} catch { /* cancelled */ }
}
function handleClose() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
</script>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
</style>
+45 -23
View File
@@ -39,6 +39,7 @@ import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import type { TemplateItem } from '@/types'
import logger from '@/utils/logger'
const appStore = useAppStore()
const visible = ref(false)
@@ -50,8 +51,12 @@ watch(() => appStore.isTemplateDialogOpen, (val) => {
})
async function loadTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
templates.value = await invoke('template_list')
try {
const { invoke } = await import('@tauri-apps/api/core')
templates.value = await invoke('template_list')
} catch (e) {
logger.error('loadTemplates failed', 'TemplateDialog', e)
}
}
async function selectTemplate(tpl: TemplateItem) {
@@ -59,40 +64,57 @@ async function selectTemplate(tpl: TemplateItem) {
ElMessage.warning('该模板没有题目数据')
return
}
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_apply', { id: tpl.id })
if (ok) {
handleClose()
} else {
try {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_apply', { id: tpl.id })
if (ok) {
handleClose()
} else {
ElMessage.error('应用模板失败')
}
} catch (e) {
logger.error('selectTemplate failed', 'TemplateDialog', e)
ElMessage.error('应用模板失败')
}
}
async function exportTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_export_json')
if (ok) {
ElMessage.success('模板已导出')
} else if (ok === false) {
ElMessage.warning('导出失败或已取消')
try {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_export_json')
if (ok) {
ElMessage.success('模板已导出')
} else if (ok === false) {
ElMessage.warning('导出失败或已取消')
}
} catch (e) {
logger.error('exportTemplates failed', 'TemplateDialog', e)
}
}
async function importTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
const result: any = await invoke('template_import_json')
if (result) {
ElMessage.success(`已导入 ${result.count} 个模板`)
loadTemplates()
try {
const { invoke } = await import('@tauri-apps/api/core')
const result: any = await invoke('template_import_json')
if (result) {
ElMessage.success(`已导入 ${result.count} 个模板`)
loadTemplates()
}
} catch (e) {
logger.error('importTemplates failed', 'TemplateDialog', e)
}
}
async function deleteTemplate(tpl: TemplateItem) {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_delete', { id: tpl.id })
if (ok) {
ElMessage.success('已删除')
loadTemplates()
try {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_delete', { id: tpl.id })
if (ok) {
ElMessage.success('已删除')
loadTemplates()
}
} catch (e) {
logger.error('deleteTemplate failed', 'TemplateDialog', e)
}
}
+5 -2
View File
@@ -8,6 +8,7 @@ export const useAppStore = defineStore('app', () => {
const isHistoryDialogOpen = ref(false)
const isTemplateDialogOpen = ref(false)
const isRubricEditorOpen = ref(false)
const isLogDialogOpen = ref(false)
const isGrading = ref(false)
function toggleToolbar() { isToolbarVisible.value = !isToolbarVisible.value }
@@ -21,12 +22,14 @@ export const useAppStore = defineStore('app', () => {
function closeTemplateDialog() { isTemplateDialogOpen.value = false }
function openRubricEditor() { isRubricEditorOpen.value = true }
function closeRubricEditor() { isRubricEditorOpen.value = false }
function openLogDialog() { isLogDialogOpen.value = true }
function closeLogDialog() { isLogDialogOpen.value = false }
return {
isToolbarVisible, isResultDrawerOpen, isSettingDialogOpen, isHistoryDialogOpen,
isTemplateDialogOpen, isRubricEditorOpen, isGrading,
isTemplateDialogOpen, isRubricEditorOpen, isLogDialogOpen, isGrading,
toggleToolbar, openResultDrawer, closeResultDrawer, openSettingDialog, closeSettingDialog,
openHistoryDialog, closeHistoryDialog, openTemplateDialog, closeTemplateDialog,
openRubricEditor, closeRubricEditor
openRubricEditor, closeRubricEditor, openLogDialog, closeLogDialog
}
})
+13 -3
View File
@@ -1,10 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { AppSettings } from '@/types'
import logger from '@/utils/logger'
export const useSettingsStore = defineStore('settings', () => {
const settings = ref<AppSettings>({
apiKey: '',
apiKeyPresets: {},
baseUrl: 'https://api.siliconflow.cn/v1',
model: '',
shortcut: 'Alt+Q',
@@ -22,13 +24,21 @@ export const useSettingsStore = defineStore('settings', () => {
if (result) {
settings.value = { ...settings.value, ...result }
}
} catch {}
} catch (e) {
logger.error('loadSettings failed', 'SettingsStore', e)
}
}
async function saveSettings(partial: Partial<AppSettings>) {
Object.assign(settings.value, partial)
const { invoke } = await import('@tauri-apps/api/core')
await invoke('settings_set', { settings: settings.value })
try {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('settings_set', { settings: settings.value })
logger.info('settings saved', 'SettingsStore')
} catch (e) {
logger.error('saveSettings failed', 'SettingsStore', e)
throw e
}
}
return { settings, loadSettings, saveSettings }
+2
View File
@@ -4,6 +4,8 @@ html[data-window="win-dialog"] { background: #f5f7fa; }
html[data-window="win-screenshot"] { background: transparent; }
body { user-select: none; }
#app { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }
html[data-window="win-dialog"] .el-dialog { display: flex; flex-direction: column; height: 100%; }
html[data-window="win-dialog"] .el-dialog .el-dialog__body { flex: 1; overflow-y: auto; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; }
+1
View File
@@ -22,6 +22,7 @@ export interface ApiConfig {
export interface AppSettings {
apiKey: string
apiKeyPresets: Record<string, string>
baseUrl: string
model: string
shortcut: string
+4 -1
View File
@@ -1,5 +1,8 @@
export function addWatermark(base64: string, text: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!base64 || typeof base64 !== 'string') {
return reject(new Error('无效的图片数据(base64 为空或格式错误)'))
}
const img = new Image()
img.onload = () => {
let w = img.width; let h = img.height; const MAX = 2048; let scale = 1
@@ -14,7 +17,7 @@ export function addWatermark(base64: string, text: string): Promise<string> {
ctx.fillText(text, padding, padding)
resolve(canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''))
}
img.onerror = () => reject(new Error('Failed to load image for watermark'))
img.onerror = () => reject(new Error('图片加载失败,请确认截图或图片文件有效'))
img.src = `data:image/png;base64,${base64}`
})
}
+49
View File
@@ -0,0 +1,49 @@
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
const LOG_ORDER: Record<LogLevel, number> = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }
const ENABLED_LEVEL: LogLevel = (import.meta.env.VITE_LOG_LEVEL as LogLevel) || 'DEBUG'
function shouldLog(level: LogLevel): boolean {
return LOG_ORDER[level] >= LOG_ORDER[ENABLED_LEVEL]
}
function now(): string {
return new Date().toISOString()
}
async function persist(level: LogLevel, message: string, source: string): Promise<void> {
if (level === 'DEBUG' || level === 'INFO') return
try {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('log_write', { level, message, source }).catch(() => {})
} catch {}
}
const logger = {
debug(message: string, source = 'app'): void {
if (!shouldLog('DEBUG')) return
console.debug(`[${now()}] [DEBUG] [${source}] ${message}`)
},
info(message: string, source = 'app'): void {
if (!shouldLog('INFO')) return
console.info(`[${now()}] [INFO] [${source}] ${message}`)
},
warn(message: string, source = 'app'): void {
if (!shouldLog('WARN')) return
console.warn(`[${now()}] [WARN] [${source}] ${message}`)
persist('WARN', message, source)
},
error(message: string, source = 'app', error?: unknown): void {
if (!shouldLog('ERROR')) return
const detail = error ? ` | ${String((error as any)?.message ?? error)}` : ''
const full = `${message}${detail}`
console.error(`[${now()}] [ERROR] [${source}] ${full}`)
persist('ERROR', full, source)
},
}
export default logger