feat: 完整日志系统 + 设置增强 + 子窗口交互优化
This commit is contained in:
@@ -4,3 +4,4 @@ src-tauri/target/
|
||||
*.db
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
+1
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
try {
|
||||
const result = gradingStore.currentResult
|
||||
if (!result) return
|
||||
try {
|
||||
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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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() {
|
||||
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,6 +64,7 @@ async function selectTemplate(tpl: TemplateItem) {
|
||||
ElMessage.warning('该模板没有题目数据')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const ok: boolean = await invoke('template_apply', { id: tpl.id })
|
||||
if (ok) {
|
||||
@@ -66,9 +72,14 @@ async function selectTemplate(tpl: TemplateItem) {
|
||||
} else {
|
||||
ElMessage.error('应用模板失败')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('selectTemplate failed', 'TemplateDialog', e)
|
||||
ElMessage.error('应用模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTemplates() {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const ok: boolean = await invoke('template_export_json')
|
||||
if (ok) {
|
||||
@@ -76,24 +87,35 @@ async function exportTemplates() {
|
||||
} else if (ok === false) {
|
||||
ElMessage.warning('导出失败或已取消')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('exportTemplates failed', 'TemplateDialog', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function importTemplates() {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
|
||||
+5
-2
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
+11
-1
@@ -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)
|
||||
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 }
|
||||
|
||||
@@ -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; }
|
||||
@@ -22,6 +22,7 @@ export interface ApiConfig {
|
||||
|
||||
export interface AppSettings {
|
||||
apiKey: string
|
||||
apiKeyPresets: Record<string, string>
|
||||
baseUrl: string
|
||||
model: string
|
||||
shortcut: string
|
||||
|
||||
+4
-1
@@ -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}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user