diff --git a/.gitignore b/.gitignore index fe70a56..e109f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ src-tauri/target/ *.db .DS_Store *.log +.env \ No newline at end of file diff --git a/package.json b/package.json index b0fbd39..0f7a052 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs index 0b8a1d1..b1ed835 100644 --- a/src-tauri/src/ai.rs +++ b/src-tauri/src/ai.rs @@ -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); } diff --git a/src-tauri/src/commands/log.rs b/src-tauri/src/commands/log.rs new file mode 100644 index 0000000..6b96451 --- /dev/null +++ b/src-tauri/src/commands/log.rs @@ -0,0 +1,22 @@ +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub fn log_write(state: State, 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, limit: Option) -> Result, 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) -> Result<(), String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.clear_logs(); + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 855b22a..0f5a891 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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 Result, 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 = 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) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 7abe1e8..e2f916d 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -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, 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 { 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 { pub async fn settings_set(app: AppHandle, settings: AppSettings) -> Result { 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)); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5f2a295..5667c8b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 { + 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", []); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aef3707..a0fa19c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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(()) } diff --git a/src/App.vue b/src/App.vue index 4452d2a..85e0bf6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,6 +3,7 @@ + @@ -12,15 +13,16 @@ diff --git a/src/pages/TemplateDialog.vue b/src/pages/TemplateDialog.vue index 3345f76..8b1e660 100644 --- a/src/pages/TemplateDialog.vue +++ b/src/pages/TemplateDialog.vue @@ -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) } } diff --git a/src/stores/app.ts b/src/stores/app.ts index a0e9626..75a0e29 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -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 } }) diff --git a/src/stores/settings.ts b/src/stores/settings.ts index cea54d5..36464c7 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -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({ 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) { 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 } diff --git a/src/styles/main.css b/src/styles/main.css index 5f493b8..6909dba 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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; } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 0bbc709..e94bbac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,7 @@ export interface ApiConfig { export interface AppSettings { apiKey: string + apiKeyPresets: Record baseUrl: string model: string shortcut: string diff --git a/src/utils/image.ts b/src/utils/image.ts index 15ee546..15e216b 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,5 +1,8 @@ export function addWatermark(base64: string, text: string): Promise { 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 { 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}` }) } diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..955ba4b --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,49 @@ +export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' + +const LOG_ORDER: Record = { 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 { + 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