Initial commit: AI 阅卷助手 Tauri v2 + Vue 3
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
*.db
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 AI 阅卷助手
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,83 @@
|
||||
# AI 阅卷助手 (AI Grading Assistant)
|
||||
|
||||
基于 Tauri v2 + Vue 3 的桌面端 AI 辅助阅卷工具,支持截图批改、评分标准管理、历史记录等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层面 | 技术 |
|
||||
| -------- | --------------------------------------------- |
|
||||
| 前端 | Vue 3 + TypeScript + Pinia + Vue Router |
|
||||
| UI | Element Plus |
|
||||
| 桌面框架 | Tauri v2 (Rust) |
|
||||
| 后端 | Rust (rusqlite, reqwest, xcap, image, base64) |
|
||||
| 数据库 | SQLite (bundled via rusqlite) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 18
|
||||
- [Rust](https://rustup.rs/) (stable)
|
||||
- Windows: [Microsoft Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) 或 Visual Studio (C++ 工作负载)
|
||||
- 系统依赖参考 [Tauri v2 前置条件文档](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 1. 安装前端依赖
|
||||
npm install
|
||||
|
||||
# 2. 启动开发模式(自动启动 Vite HMR + Tauri 窗口)
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Tauri 开发模式会先启动 Vite 开发服务器(端口 5173),然后打开 Tauri 应用窗口,支持热更新。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# 1. 构建前端 + Rust 二进制
|
||||
npm run tauri build
|
||||
|
||||
# 2. 产物位于 src-tauri/target/release/bundle/
|
||||
# - Windows: MSI 安装包 (.msi) 或 NSIS 安装程序 (.exe)
|
||||
# - 便携版: src-tauri/target/release/ai-grading-assistant.exe
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
AIGA-Tauri/
|
||||
├── src/ # Vue 前端源码
|
||||
│ ├── components/ # 组件(FloatingToolbar, ResultDrawer, 等)
|
||||
│ ├── pages/ # 页面级组件(HistoryDialog, TemplateDialog)
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ ├── styles/ # 全局样式
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口
|
||||
├── src-tauri/ # Rust 后端源码
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # Tauri IPC 命令
|
||||
│ │ ├── ai.rs # AI 评分逻辑
|
||||
│ │ ├── db.rs # SQLite 数据库
|
||||
│ │ └── lib.rs # Tauri 应用入口、窗口管理
|
||||
│ ├── icons/ # 应用图标
|
||||
│ ├── Cargo.toml
|
||||
│ └── tauri.conf.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
- **悬浮工具栏**:全局置顶,支持拖拽,快捷键截图
|
||||
- **截图批改**:框选区域后调用 AI 模型自动评分
|
||||
- **评分标准管理**:多题目、评分细则、参考答案(含图片)
|
||||
- **模板管理**:保存/导入/导出评分模板
|
||||
- **设置**:API Key / Base URL / 模型 / 快捷键 / 代理等
|
||||
- **历史记录**:评分记录查询、详情查看、删除
|
||||
- **系统托盘**:关闭窗口后后台运行,托盘菜单唤出
|
||||
|
||||
## 许可
|
||||
|
||||
MIT
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI 阅卷助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2443
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ai-grading-assistant",
|
||||
"version": "1.0.0",
|
||||
"description": "AI 阅卷助手 - Tauri版桌面端 AI Copilot",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit --skipLibCheck && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^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",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^3.3.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dir = path.join(__dirname, '..', 'src-tauri', 'icons');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Pre-computed CRC32 for IHDR, IDAT, IEND chunks
|
||||
// 1x1 white pixel PNG
|
||||
const pngBuf = Buffer.from([
|
||||
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,
|
||||
0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
|
||||
0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
|
||||
0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53,
|
||||
0xDE,
|
||||
0x00,0x00,0x00,0x0C,0x49,0x44,0x41,0x54,
|
||||
0x08,0xD7,0x63,0x60,0x60,0x60,0x00,
|
||||
0x00,0x00,0x04,0x00,0x01,0x27,0x34,0x27,
|
||||
0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,
|
||||
0xAE,0x42,0x60,0x82
|
||||
]);
|
||||
|
||||
fs.writeFileSync(path.join(dir, '32x32.png'), pngBuf);
|
||||
fs.writeFileSync(path.join(dir, '128x128.png'), pngBuf);
|
||||
fs.writeFileSync(path.join(dir, '128x128@2x.png'), pngBuf);
|
||||
fs.writeFileSync(path.join(dir, 'icon.png'), pngBuf);
|
||||
|
||||
// Create ICO file
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(1, 4);
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry.writeUInt8(1, 0);
|
||||
entry.writeUInt8(1, 1);
|
||||
entry.writeUInt8(0, 2);
|
||||
entry.writeUInt8(0, 3);
|
||||
entry.writeUInt16LE(1, 4);
|
||||
entry.writeUInt16LE(32, 6);
|
||||
entry.writeUInt32LE(pngBuf.length, 8);
|
||||
entry.writeUInt32LE(22, 12);
|
||||
|
||||
const ico = Buffer.concat([header, entry, pngBuf]);
|
||||
fs.writeFileSync(path.join(dir, 'icon.ico'), ico);
|
||||
|
||||
console.log('All icons created in ' + dir);
|
||||
Generated
+6464
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "ai-grading-assistant"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ai_grading_assistant_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "ai-grading-assistant"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
base64 = "0.22"
|
||||
xcap = "0.4"
|
||||
image = "0.25"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capability set",
|
||||
"windows": ["*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:event:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"global-shortcut:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"store:default",
|
||||
"fs:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability set","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-close","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-set-focus","core:window:allow-hide","core:window:allow-show","core:window:allow-set-always-on-top","core:window:allow-center","core:window:allow-start-dragging","core:event:default","core:event:allow-listen","core:event:allow-emit","dialog:default","dialog:allow-open","dialog:allow-save","global-shortcut:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","fs:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,211 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiConfig {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GradeResult {
|
||||
pub score: f64,
|
||||
pub confidence: f64,
|
||||
pub deductions: Vec<String>,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
pub async fn call_ai_api(
|
||||
image_base64: &str,
|
||||
rubric: &str,
|
||||
config: &ApiConfig,
|
||||
max_score: Option<f64>,
|
||||
reference_answer: Option<&str>,
|
||||
question_title: Option<&str>,
|
||||
reference_answer_image: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<GradeResult, String> {
|
||||
let system_role = "你是一名高校阅卷老师,根据评分标准和答案对学生答案评分。请输出JSON格式的结果。";
|
||||
|
||||
let has_ref_image = reference_answer_image.is_some_and(|s| !s.is_empty());
|
||||
let max_s = max_score.unwrap_or(100.0);
|
||||
|
||||
let prompt = if has_ref_image {
|
||||
format!(
|
||||
"题目:{}\n评分标准:{}\n参考答案:{}\n满分:{}\n\n图片1是标准答案,图片2是学生答案。\n请根据评分标准和标准答案(图片1)给学生答案(图片2)评分。\n\n评分规则:\n1. 逐项评分,各项得分之和不超过满分{}\n2. 空白或无关答案给0分\n3. 部分正确酌情给分\n\n请输出JSON:\n{{\"score\":分数,\"confidence\":置信度0~1,\"deductions\":[\"扣分项\"],\"comment\":\"评语\"}}\n\n只输出JSON,不要包含其他文字和markdown标记。",
|
||||
question_title.unwrap_or(""),
|
||||
rubric,
|
||||
reference_answer.unwrap_or("无"),
|
||||
max_s,
|
||||
max_s
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"题目:{}\n评分标准:{}\n参考答案:{}\n满分:{}\n\n图片是学生答案,请根据评分标准评分。\n\n评分规则:\n1. 逐项评分,各项得分之和不超过满分{}\n2. 空白或无关答案给0分\n3. 部分正确酌情给分\n\n请输出JSON:\n{{\"score\":分数,\"confidence\":置信度0~1,\"deductions\":[\"扣分项\"],\"comment\":\"评语\"}}\n\n只输出JSON,不要包含其他文字和markdown标记。",
|
||||
question_title.unwrap_or(""),
|
||||
rubric,
|
||||
reference_answer.unwrap_or("无"),
|
||||
max_s,
|
||||
max_s
|
||||
)
|
||||
};
|
||||
|
||||
let body = if has_ref_image {
|
||||
serde_json::json!({
|
||||
"model": config.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_role},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": &prompt},
|
||||
{"type": "image_url", "image_url": {"url": reference_answer_image.unwrap_or("")}},
|
||||
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
|
||||
]}
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.3
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"model": config.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_role},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": &prompt},
|
||||
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
|
||||
]}
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.3
|
||||
})
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 1..=3 {
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", config.api_key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
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") {
|
||||
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
|
||||
} else {
|
||||
last_error = format!("API 返回错误 ({}): {}", status, &error_text);
|
||||
}
|
||||
log::warn!("AI API attempt {}/3 failed: {}", attempt, last_error);
|
||||
if attempt < 3 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(last_error);
|
||||
}
|
||||
|
||||
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") {
|
||||
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") {
|
||||
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
|
||||
return Err(last_error);
|
||||
}
|
||||
}
|
||||
let content = json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if content.is_empty() {
|
||||
last_error = format!("API 返回为空 (尝试 {}/3)", attempt);
|
||||
log::warn!("AI API empty response, attempt {}/3", attempt);
|
||||
if attempt < 3 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(last_error);
|
||||
}
|
||||
match try_parse_json(&content) {
|
||||
Some(parsed) => {
|
||||
return Ok(GradeResult {
|
||||
score: parsed["score"].as_f64().unwrap_or(0.0),
|
||||
confidence: parsed["confidence"].as_f64().unwrap_or(0.5),
|
||||
deductions: parsed["deductions"].as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default(),
|
||||
comment: parsed["comment"].as_str().or_else(|| parsed["commit"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
last_error = format!("AI 返回 JSON 解析失败 (尝试 {}/3)", attempt);
|
||||
log::warn!("AI JSON parse failed, attempt {}/3: {}", attempt, &content[..content.len().min(200)]);
|
||||
if attempt < 3 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(last_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = format!("API request failed: {}", e);
|
||||
log::warn!("AI API attempt {}/3 failed: {}", attempt, last_error);
|
||||
if attempt < 3 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(last_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
fn try_parse_json(text: &str) -> Option<Value> {
|
||||
let mut cleaned = text.trim().to_string();
|
||||
// remove markdown code fences
|
||||
cleaned = cleaned.replace("```json", "").replace("```JSON", "").replace("```", "").trim().to_string();
|
||||
// if there's still non-JSON text, extract the first {...} object
|
||||
if !cleaned.starts_with('{') && !cleaned.starts_with('[') {
|
||||
if let Some(start) = cleaned.find('{') {
|
||||
if let Some(end) = cleaned.rfind('}') {
|
||||
cleaned = cleaned[start..=end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
// attempt direct parse
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
|
||||
return Some(v);
|
||||
}
|
||||
// fix single quotes -> double quotes
|
||||
let fixed = cleaned
|
||||
.replace('\'', "\"")
|
||||
.replace(",\n]", "\n]")
|
||||
.replace(",\n}", "\n}")
|
||||
.replace(",]", "]")
|
||||
.replace(",}", "}");
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&fixed) {
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradingSubmitData {
|
||||
pub question_id: Option<i64>,
|
||||
pub image_path: Option<String>,
|
||||
pub ai_score: f64,
|
||||
pub teacher_score: f64,
|
||||
pub confidence: f64,
|
||||
pub student_answer: Option<String>,
|
||||
pub question_title: Option<String>,
|
||||
pub max_score: Option<f64>,
|
||||
pub reference_answer: Option<String>,
|
||||
pub rubric: Option<String>,
|
||||
pub deductions: Option<Vec<String>>,
|
||||
pub comment: Option<String>,
|
||||
pub result_json: Option<String>,
|
||||
pub image_data: Option<String>,
|
||||
pub reference_answer_image: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn grading_submit(state: State<AppState>, data: GradingSubmitData) -> Result<bool, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let json = serde_json::json!({
|
||||
"questionId": data.question_id.unwrap_or(0),
|
||||
"imagePath": data.image_path.unwrap_or_default(),
|
||||
"studentAnswer": data.student_answer.unwrap_or_default(),
|
||||
"aiScore": data.ai_score,
|
||||
"teacherScore": data.teacher_score,
|
||||
"confidence": data.confidence,
|
||||
"questionTitle": data.question_title.unwrap_or_default(),
|
||||
"maxScore": data.max_score.unwrap_or(100.0),
|
||||
"referenceAnswer": data.reference_answer.unwrap_or_default(),
|
||||
"rubric": data.rubric.unwrap_or_default(),
|
||||
"deductions": serde_json::to_string(&data.deductions.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string()),
|
||||
"comment": data.comment.unwrap_or_default(),
|
||||
"resultJson": data.result_json.unwrap_or_default(),
|
||||
"imageData": data.image_data.unwrap_or_default(),
|
||||
"referenceAnswerImage": data.reference_answer_image.unwrap_or_default(),
|
||||
});
|
||||
Ok(db.insert_history(&json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn grading_store_data(state: State<AppState>, data: serde_json::Value) -> Result<(), String> {
|
||||
let mut payload = state.grading_payload.lock().map_err(|e| e.to_string())?;
|
||||
*payload = Some(data.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn grading_get_data(state: State<AppState>) -> Result<Option<serde_json::Value>, String> {
|
||||
let mut payload = state.grading_payload.lock().map_err(|e| e.to_string())?;
|
||||
let data = payload.take();
|
||||
match data {
|
||||
Some(s) => serde_json::from_str(&s).map(Some).map_err(|e| e.to_string()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::AppState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn history_list(state: State<AppState>) -> Result<Vec<serde_json::Value>, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db.list_history())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn history_delete(state: State<AppState>, id: i64) -> Result<bool, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db.delete_history(id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn history_get_by_id(state: State<AppState>, id: i64) -> Result<Option<serde_json::Value>, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db.get_history_by_id(id))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
pub mod settings;
|
||||
pub mod screenshot;
|
||||
pub mod history;
|
||||
pub mod grading;
|
||||
pub mod rubric;
|
||||
pub mod template;
|
||||
|
||||
use crate::ai;
|
||||
use crate::create_dialog_window;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ImageResult {
|
||||
pub base64: String,
|
||||
pub mime: String,
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dialog_open(app: AppHandle, dialog_type: String) -> Result<(), String> {
|
||||
let sizes: [(&str, f64, f64); 5] = [
|
||||
("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),
|
||||
];
|
||||
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())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dialog_open_image(app: AppHandle) -> Result<Option<ImageResult>, String> {
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.add_filter("图片", &["png", "jpg", "jpeg", "bmp", "gif", "webp"])
|
||||
.blocking_pick_file();
|
||||
match file {
|
||||
Some(path) => {
|
||||
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
|
||||
let ext = std::path::Path::new(&path_str)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png")
|
||||
.to_lowercase();
|
||||
let mime = if ext == "jpg" { "jpeg".to_string() } else { ext.clone() };
|
||||
let bytes = std::fs::read(&path_str).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let base64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(Some(ImageResult { base64, mime, file_path: path_str }))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradeRequest {
|
||||
pub image_base64: String,
|
||||
pub rubric: String,
|
||||
pub api_config: ai::ApiConfig,
|
||||
pub max_score: Option<f64>,
|
||||
pub reference_answer: Option<String>,
|
||||
pub question_title: Option<String>,
|
||||
pub reference_answer_image: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_grade(app: AppHandle, request: GradeRequest) -> Result<ai::GradeResult, String> {
|
||||
let timeout = app.store("settings.json")
|
||||
.ok()
|
||||
.and_then(|store| store.get("timeout").and_then(|v| v.as_i64().map(|i| i as u64)))
|
||||
.unwrap_or(120);
|
||||
|
||||
ai::call_ai_api(
|
||||
&request.image_base64,
|
||||
&request.rubric,
|
||||
&request.api_config,
|
||||
request.max_score,
|
||||
request.reference_answer.as_deref(),
|
||||
request.question_title.as_deref(),
|
||||
request.reference_answer_image.as_deref(),
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RubricData {
|
||||
pub questions: Vec<QuestionItem>,
|
||||
pub current_index: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionItem {
|
||||
pub question_title: String,
|
||||
pub max_score: f64,
|
||||
pub reference_answer: String,
|
||||
pub rubric: String,
|
||||
pub reference_answer_image: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rubric_store_data(app: AppHandle, state: State<AppState>, data: RubricData) -> Result<(), String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db.clear_rubric();
|
||||
for (i, q) in data.questions.iter().enumerate() {
|
||||
db.insert_rubric_question(&serde_json::json!(q), i as i32);
|
||||
}
|
||||
drop(db);
|
||||
let _ = app.emit("rubric-updated", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rubric_get_data(state: State<AppState>) -> Result<Option<serde_json::Value>, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let questions = db.get_rubric_questions();
|
||||
if questions.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(serde_json::json!({
|
||||
"questions": questions,
|
||||
"currentIndex": 0
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rubric_export_json(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let questions = {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db.get_rubric_questions()
|
||||
};
|
||||
if questions.is_empty() {
|
||||
return Err("暂无评分标准可导出".to_string());
|
||||
}
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.set_file_name("评分标准.json")
|
||||
.blocking_save_file();
|
||||
match file {
|
||||
Some(path) => {
|
||||
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
|
||||
let content = serde_json::to_string_pretty(&questions).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path_str, &content).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rubric_import_json(app: AppHandle) -> Result<Option<serde_json::Value>, String> {
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.blocking_pick_file();
|
||||
match file {
|
||||
Some(path) => {
|
||||
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
|
||||
let content = std::fs::read_to_string(&path_str).map_err(|e| e.to_string())?;
|
||||
let questions: Vec<serde_json::Value> = serde_json::from_str(&content).map_err(|e| format!("JSON 格式错误:{}", e))?;
|
||||
Ok(Some(serde_json::json!({
|
||||
"questions": questions,
|
||||
"currentIndex": 0
|
||||
})))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rubric_get_next_index(state: State<AppState>) -> Result<i32, String> {
|
||||
let idx = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
|
||||
Ok(*idx)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rubric_set_next_index(state: State<AppState>, idx: i32) -> Result<(), String> {
|
||||
let mut next = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
|
||||
*next = idx;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rubric_reset_next_index(state: State<AppState>) -> Result<(), String> {
|
||||
let mut next = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
|
||||
*next = 0;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use crate::get_screenshot_window;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CaptureRegion {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_start(app: AppHandle) -> Result<(), String> {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.hide();
|
||||
}
|
||||
get_screenshot_window(&app).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_monitor_at_cursor(app: &AppHandle) -> Result<xcap::Monitor, String> {
|
||||
let monitors = xcap::Monitor::all().map_err(|e| format!("Failed to list monitors: {}", e))?;
|
||||
let cursor = app.get_webview_window("main").and_then(|w| w.outer_position().ok());
|
||||
for m in monitors {
|
||||
let mx = m.x().map_err(|e| format!("Monitor x error: {}", e))?;
|
||||
let my = m.y().map_err(|e| format!("Monitor y error: {}", e))?;
|
||||
let mw = m.width().map_err(|e| format!("Monitor width error: {}", e))? as i32;
|
||||
let mh = m.height().map_err(|e| format!("Monitor height error: {}", e))? as i32;
|
||||
if let Some(c) = cursor {
|
||||
let cx = c.x as i32;
|
||||
let cy = c.y as i32;
|
||||
if cx >= mx && cx < (mx + mw) && cy >= my && cy < (my + mh) {
|
||||
return Ok(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
xcap::Monitor::all()
|
||||
.map_err(|e| format!("Failed to list monitors: {}", e))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "No monitor found".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_capture(app: AppHandle, region: CaptureRegion) -> Result<Option<String>, String> {
|
||||
let target = get_monitor_at_cursor(&app)?;
|
||||
|
||||
let img = target.capture_image().map_err(|e| format!("Failed to capture screen: {}", e))?;
|
||||
let (img_w, img_h) = (img.width() as f64, img.height() as f64);
|
||||
let (disp_w, disp_h) = (
|
||||
target.width().map_err(|e| format!("Monitor width error: {}", e))? as f64,
|
||||
target.height().map_err(|e| format!("Monitor height error: {}", e))? as f64,
|
||||
);
|
||||
|
||||
let ratio_x = img_w / disp_w;
|
||||
let ratio_y = img_h / disp_h;
|
||||
|
||||
let crop_x = (region.x as f64 * ratio_x).max(0.0) as u32;
|
||||
let crop_y = (region.y as f64 * ratio_y).max(0.0) as u32;
|
||||
let crop_w = (region.width as f64 * ratio_x).min((img_w - crop_x as f64).max(0.0)) as u32;
|
||||
let crop_h = (region.height as f64 * ratio_y).min((img_h - crop_y as f64).max(0.0)) as u32;
|
||||
|
||||
let cropped = image::DynamicImage::ImageRgba8(img).crop_imm(crop_x, crop_y, crop_w, crop_h);
|
||||
let mut png_bytes: Vec<u8> = Vec::new();
|
||||
cropped.write_to(&mut std::io::Cursor::new(&mut png_bytes), image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
|
||||
|
||||
let base64_str = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
|
||||
|
||||
if let Ok(store) = tauri_plugin_store::StoreExt::store(&app, "settings.json") {
|
||||
if let Some(path) = store.get("screenshotSavePath").and_then(|v| v.as_str().map(|s| s.to_string())) {
|
||||
if !path.is_empty() {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis();
|
||||
let filename = format!("screenshot_{}.png", timestamp);
|
||||
let full_path = std::path::Path::new(&path).join(&filename);
|
||||
let _ = std::fs::write(&full_path, &png_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sw) = app.get_webview_window("screenshot") {
|
||||
let _ = sw.close();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
let _ = app.emit("screenshot-completed", &base64_str);
|
||||
Ok(Some(base64_str))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_cancel(app: AppHandle) -> Result<(), String> {
|
||||
if let Some(sw) = app.get_webview_window("screenshot") {
|
||||
let _ = sw.close();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppSettings {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub shortcut: String,
|
||||
pub theme: String,
|
||||
pub font_size: i32,
|
||||
pub proxy: String,
|
||||
pub timeout: i32,
|
||||
pub screenshot_save_path: String,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
AppSettings {
|
||||
api_key: String::new(),
|
||||
base_url: "https://api.siliconflow.cn/v1".to_string(),
|
||||
model: String::new(),
|
||||
shortcut: "Alt+Q".to_string(),
|
||||
theme: "light".to_string(),
|
||||
font_size: 14,
|
||||
proxy: String::new(),
|
||||
timeout: 120,
|
||||
screenshot_save_path: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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(),
|
||||
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()),
|
||||
theme: store.get("theme").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "light".to_string()),
|
||||
font_size: store.get("fontSize").and_then(|v| v.as_i64().map(|i| i as i32)).unwrap_or(14),
|
||||
proxy: store.get("proxy").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
|
||||
timeout: store.get("timeout").and_then(|v| v.as_i64().map(|i| {
|
||||
let t = i as i32;
|
||||
if t < 60 { 120 } else { t }
|
||||
})).unwrap_or(120),
|
||||
screenshot_save_path: store.get("screenshotSavePath").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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("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));
|
||||
store.set("theme", serde_json::Value::String(settings.theme));
|
||||
store.set("fontSize", serde_json::Value::Number(serde_json::Number::from(settings.font_size)));
|
||||
store.set("proxy", serde_json::Value::String(settings.proxy));
|
||||
store.set("timeout", serde_json::Value::Number(serde_json::Number::from(settings.timeout)));
|
||||
store.set("screenshotSavePath", serde_json::Value::String(settings.screenshot_save_path));
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TemplateSaveData {
|
||||
pub name: String,
|
||||
pub course: String,
|
||||
pub questions: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn template_list(state: State<AppState>) -> Result<Vec<serde_json::Value>, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db.list_templates())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn template_save(state: State<AppState>, data: TemplateSaveData) -> Result<i64, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let questions_json = serde_json::to_string(&data.questions.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string());
|
||||
Ok(db.save_template(&data.name, &data.course, &questions_json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn template_delete(state: State<AppState>, id: i64) -> Result<bool, String> {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db.delete_template(id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn template_apply(app: AppHandle, state: State<AppState>, id: i64) -> Result<bool, String> {
|
||||
let tpl = {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db.get_template_by_id(id)
|
||||
};
|
||||
match tpl {
|
||||
Some(t) => {
|
||||
let questions = t["questions"].as_array().ok_or("模板中没有题目")?.clone();
|
||||
if questions.is_empty() {
|
||||
return Err("模板中没有题目".to_string());
|
||||
}
|
||||
{
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db.clear_rubric();
|
||||
for (i, q) in questions.iter().enumerate() {
|
||||
db.insert_rubric_question(q, i as i32);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut idx = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
|
||||
*idx = 0;
|
||||
}
|
||||
let _ = app.emit("rubric-updated", ());
|
||||
Ok(true)
|
||||
}
|
||||
None => Err("模板不存在".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn template_export_json(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let templates = {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db.list_templates()
|
||||
};
|
||||
if templates.is_empty() {
|
||||
return Err("暂无模板可导出".to_string());
|
||||
}
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.set_file_name("评分模板.json")
|
||||
.blocking_save_file();
|
||||
match file {
|
||||
Some(path) => {
|
||||
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
|
||||
let export_data: Vec<serde_json::Value> = templates.iter().map(|t| {
|
||||
serde_json::json!({
|
||||
"name": t["name"],
|
||||
"course": t["course"],
|
||||
"questions": t["questions"],
|
||||
"created_at": t["created_at"]
|
||||
})
|
||||
}).collect();
|
||||
let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path_str, &content).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn template_import_json(app: AppHandle, state: State<'_, AppState>) -> Result<Option<serde_json::Value>, String> {
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.blocking_pick_file();
|
||||
match file {
|
||||
Some(path) => {
|
||||
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
|
||||
let content = std::fs::read_to_string(&path_str).map_err(|e| e.to_string())?;
|
||||
let data: Vec<serde_json::Value> = serde_json::from_str(&content).map_err(|e| format!("JSON 格式错误:应为数组 - {}", e))?;
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut count = 0;
|
||||
for tpl in &data {
|
||||
if let (Some(name), Some(course)) = (tpl["name"].as_str(), tpl["course"].as_str()) {
|
||||
let questions_json = serde_json::to_string(&tpl["questions"]).unwrap_or_else(|_| "[]".to_string());
|
||||
db.save_template(name, course, &questions_json);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(Some(serde_json::json!({ "count": count })))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("Failed to create database directory");
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap_or_else(|e| {
|
||||
panic!("Failed to open database at {}: {}", path.display(), e)
|
||||
});
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_id INTEGER DEFAULT 0,
|
||||
image_path TEXT DEFAULT '',
|
||||
student_answer TEXT DEFAULT '',
|
||||
ai_score REAL DEFAULT 0,
|
||||
teacher_score REAL DEFAULT 0,
|
||||
confidence REAL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
question_title TEXT DEFAULT '',
|
||||
max_score REAL DEFAULT 100,
|
||||
reference_answer TEXT DEFAULT '',
|
||||
rubric TEXT DEFAULT '',
|
||||
deductions TEXT DEFAULT '',
|
||||
comment TEXT DEFAULT '',
|
||||
result_json TEXT DEFAULT '',
|
||||
image_data TEXT DEFAULT '',
|
||||
reference_answer_image TEXT DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS exam (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
course TEXT NOT NULL DEFAULT '',
|
||||
questions_json TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rubric (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_title TEXT NOT NULL DEFAULT '',
|
||||
max_score REAL NOT NULL DEFAULT 100,
|
||||
reference_answer TEXT NOT NULL DEFAULT '',
|
||||
rubric TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
reference_answer_image TEXT NOT NULL DEFAULT ''
|
||||
);"
|
||||
).expect("Failed to create tables");
|
||||
Database { conn }
|
||||
}
|
||||
|
||||
pub fn get_conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
// History methods
|
||||
pub fn list_history(&self) -> Vec<serde_json::Value> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT * FROM history ORDER BY created_at DESC LIMIT 100"
|
||||
).unwrap();
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let question_id: i64 = row.get(1)?;
|
||||
let image_path: String = row.get(2)?;
|
||||
let student_answer: String = row.get(3)?;
|
||||
let ai_score: f64 = row.get(4)?;
|
||||
let teacher_score: f64 = row.get(5)?;
|
||||
let confidence: f64 = row.get(6)?;
|
||||
let created_at: String = row.get(7)?;
|
||||
let question_title: String = row.get(8)?;
|
||||
let max_score: f64 = row.get(9)?;
|
||||
let reference_answer: String = row.get(10)?;
|
||||
let rubric: String = row.get(11)?;
|
||||
let deductions: String = row.get(12)?;
|
||||
let comment: String = row.get(13)?;
|
||||
let result_json: String = row.get(14)?;
|
||||
let image_data: String = row.get(15)?;
|
||||
let reference_answer_image: String = row.get(16)?;
|
||||
Ok(serde_json::json!({
|
||||
"id": id, "question_id": question_id, "image_path": image_path,
|
||||
"student_answer": student_answer, "ai_score": ai_score,
|
||||
"teacher_score": teacher_score, "confidence": confidence,
|
||||
"created_at": created_at, "question_title": question_title,
|
||||
"max_score": max_score, "reference_answer": reference_answer,
|
||||
"rubric": rubric, "deductions": deductions, "comment": comment,
|
||||
"result_json": result_json, "image_data": image_data,
|
||||
"reference_answer_image": reference_answer_image
|
||||
}))
|
||||
}).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
pub fn get_history_by_id(&self, id: i64) -> Option<serde_json::Value> {
|
||||
self.conn.prepare(
|
||||
"SELECT * FROM history WHERE id = ?"
|
||||
).ok().and_then(|mut stmt| {
|
||||
stmt.query_row(params![id], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"question_id": row.get::<_, i64>(1)?,
|
||||
"image_path": row.get::<_, String>(2)?,
|
||||
"student_answer": row.get::<_, String>(3)?,
|
||||
"ai_score": row.get::<_, f64>(4)?,
|
||||
"teacher_score": row.get::<_, f64>(5)?,
|
||||
"confidence": row.get::<_, f64>(6)?,
|
||||
"created_at": row.get::<_, String>(7)?,
|
||||
"question_title": row.get::<_, String>(8)?,
|
||||
"max_score": row.get::<_, f64>(9)?,
|
||||
"reference_answer": row.get::<_, String>(10)?,
|
||||
"rubric": row.get::<_, String>(11)?,
|
||||
"deductions": row.get::<_, String>(12)?,
|
||||
"comment": row.get::<_, String>(13)?,
|
||||
"result_json": row.get::<_, String>(14)?,
|
||||
"image_data": row.get::<_, String>(15)?,
|
||||
"reference_answer_image": row.get::<_, String>(16)?
|
||||
}))
|
||||
}).ok()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_history(&self, id: i64) -> bool {
|
||||
self.conn.execute("DELETE FROM history WHERE id = ?", params![id]).is_ok()
|
||||
}
|
||||
|
||||
pub fn insert_history(&self, data: &serde_json::Value) -> bool {
|
||||
self.conn.execute(
|
||||
"INSERT INTO history (question_id, image_path, student_answer, ai_score, teacher_score, confidence, question_title, max_score, reference_answer, rubric, deductions, comment, result_json, image_data, reference_answer_image) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
params![
|
||||
data["questionId"].as_i64().unwrap_or(0),
|
||||
data["imagePath"].as_str().unwrap_or(""),
|
||||
data["studentAnswer"].as_str().unwrap_or(""),
|
||||
data["aiScore"].as_f64().unwrap_or(0.0),
|
||||
data["teacherScore"].as_f64().unwrap_or(0.0),
|
||||
data["confidence"].as_f64().unwrap_or(0.0),
|
||||
data["questionTitle"].as_str().unwrap_or(""),
|
||||
data["maxScore"].as_f64().unwrap_or(100.0),
|
||||
data["referenceAnswer"].as_str().unwrap_or(""),
|
||||
data["rubric"].as_str().unwrap_or(""),
|
||||
data["deductions"].as_str().unwrap_or("[]"),
|
||||
data["comment"].as_str().unwrap_or(""),
|
||||
data["resultJson"].as_str().unwrap_or(""),
|
||||
data["imageData"].as_str().unwrap_or(""),
|
||||
data["referenceAnswerImage"].as_str().unwrap_or(""),
|
||||
]
|
||||
).is_ok()
|
||||
}
|
||||
|
||||
// Rubric methods
|
||||
pub fn get_rubric_questions(&self) -> Vec<serde_json::Value> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT * FROM rubric ORDER BY sort_order"
|
||||
).unwrap();
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let q_title: String = row.get(1)?;
|
||||
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 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,
|
||||
"maxScore": q_max,
|
||||
"referenceAnswer": q_ref,
|
||||
"rubric": q_rubric,
|
||||
"referenceAnswerImage": ref_img
|
||||
}))
|
||||
}).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
pub fn clear_rubric(&self) {
|
||||
let _ = self.conn.execute("DELETE FROM rubric", []);
|
||||
}
|
||||
|
||||
pub fn insert_rubric_question(&self, q: &serde_json::Value, sort_order: i32) -> bool {
|
||||
self.conn.execute(
|
||||
"INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order, reference_answer_image) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
q["questionTitle"].as_str().unwrap_or(""),
|
||||
q["maxScore"].as_f64().unwrap_or(100.0),
|
||||
q["referenceAnswer"].as_str().unwrap_or(""),
|
||||
q["rubric"].as_str().unwrap_or(""),
|
||||
sort_order,
|
||||
q["referenceAnswerImage"].as_str().unwrap_or(""),
|
||||
]
|
||||
).is_ok()
|
||||
}
|
||||
|
||||
// Template (exam) methods
|
||||
pub fn list_templates(&self) -> Vec<serde_json::Value> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT * FROM exam ORDER BY created_at DESC"
|
||||
).unwrap();
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let course: String = row.get(2)?;
|
||||
let questions_json: String = row.get(3)?;
|
||||
let created_at: String = row.get(4)?;
|
||||
let questions: serde_json::Value = serde_json::from_str(&questions_json).unwrap_or(serde_json::Value::Array(vec![]));
|
||||
Ok(serde_json::json!({
|
||||
"id": id, "name": name, "course": course,
|
||||
"questions": questions, "created_at": created_at
|
||||
}))
|
||||
}).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
pub fn get_template_by_id(&self, id: i64) -> Option<serde_json::Value> {
|
||||
self.conn.prepare("SELECT * FROM exam WHERE id = ?").ok().and_then(|mut stmt| {
|
||||
stmt.query_row(params![id], |row| {
|
||||
let questions_json: String = row.get(3)?;
|
||||
let questions: serde_json::Value = serde_json::from_str(&questions_json).unwrap_or(serde_json::Value::Array(vec![]));
|
||||
Ok(serde_json::json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"name": row.get::<_, String>(1)?,
|
||||
"course": row.get::<_, String>(2)?,
|
||||
"questions": questions,
|
||||
"created_at": row.get::<_, String>(4)?
|
||||
}))
|
||||
}).ok()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_template(&self, name: &str, course: &str, questions: &str) -> i64 {
|
||||
self.conn.execute(
|
||||
"INSERT INTO exam (name, course, questions_json) VALUES (?1, ?2, ?3)",
|
||||
params![name, course, questions]
|
||||
).ok();
|
||||
self.conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
pub fn delete_template(&self, id: i64) -> bool {
|
||||
self.conn.execute("DELETE FROM exam WHERE id = ?", params![id]).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
mod ai;
|
||||
mod commands;
|
||||
mod db;
|
||||
|
||||
use db::Database;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, PhysicalPosition, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Database>,
|
||||
pub grading_payload: Mutex<Option<String>>,
|
||||
pub rubric_next_index: Mutex<i32>,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
let _ = window.hide();
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
let db = Database::new(app_handle.path().app_data_dir()?.join("grading.db"));
|
||||
app.manage(AppState {
|
||||
db: Mutex::new(db),
|
||||
grading_payload: Mutex::new(None),
|
||||
rubric_next_index: Mutex::new(0),
|
||||
});
|
||||
|
||||
// tray icon
|
||||
let show_item = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
let img = image::load_from_memory(include_bytes!("../icons/icon.png"))
|
||||
.expect("Failed to decode tray icon")
|
||||
.into_rgba8();
|
||||
let (iw, ih) = img.dimensions();
|
||||
let icon = Image::new_owned(img.into_raw(), iw, ih);
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("AI 阅卷助手")
|
||||
.on_menu_event(|app, event| {
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
let _ = create_main_window(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::settings::settings_get,
|
||||
commands::settings::settings_set,
|
||||
commands::screenshot::screenshot_start,
|
||||
commands::screenshot::screenshot_capture,
|
||||
commands::screenshot::screenshot_cancel,
|
||||
commands::ai_grade,
|
||||
commands::history::history_list,
|
||||
commands::history::history_delete,
|
||||
commands::history::history_get_by_id,
|
||||
commands::grading::grading_submit,
|
||||
commands::grading::grading_store_data,
|
||||
commands::grading::grading_get_data,
|
||||
commands::rubric::rubric_store_data,
|
||||
commands::rubric::rubric_get_data,
|
||||
commands::rubric::rubric_export_json,
|
||||
commands::rubric::rubric_import_json,
|
||||
commands::rubric::rubric_get_next_index,
|
||||
commands::rubric::rubric_set_next_index,
|
||||
commands::rubric::rubric_reset_next_index,
|
||||
commands::template::template_list,
|
||||
commands::template::template_save,
|
||||
commands::template::template_delete,
|
||||
commands::template::template_apply,
|
||||
commands::template::template_export_json,
|
||||
commands::template::template_import_json,
|
||||
commands::dialog_open,
|
||||
commands::dialog_open_image,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn create_main_window(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (wx, wy) = app.primary_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| {
|
||||
let scale = m.scale_factor();
|
||||
let w = m.size().width as f64 / scale;
|
||||
let x = (w - 600.0) / 2.0 + m.position().x as f64 / scale;
|
||||
let y = m.position().y as f64 / scale;
|
||||
(x, y)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0));
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
|
||||
.title("AI 阅卷助手")
|
||||
.inner_size(600.0, 64.0)
|
||||
.min_inner_size(200.0, 64.0)
|
||||
.max_inner_size(1200.0, 76.0)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.resizable(false)
|
||||
.build()?;
|
||||
window.set_position(PhysicalPosition::new(wx as i32, wy as i32))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_dialog_window(
|
||||
app: &tauri::AppHandle,
|
||||
dialog_type: &str,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let label = format!("dialog-{}", dialog_type);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
window.set_focus()?;
|
||||
return Ok(());
|
||||
}
|
||||
let (dx, dy) = get_dialog_position(app, width, height);
|
||||
WebviewWindowBuilder::new(app, &label, WebviewUrl::App(format!("index.html?dialog={}", dialog_type).into()))
|
||||
.title(match dialog_type {
|
||||
"settings" => "设置",
|
||||
"history" => "历史记录",
|
||||
"templates" => "模板管理",
|
||||
"rubric" => "评分标准",
|
||||
"result" => "评分结果",
|
||||
_ => "AI 阅卷助手",
|
||||
})
|
||||
.inner_size(width, height)
|
||||
.position(dx, dy)
|
||||
.decorations(false)
|
||||
.resizable(false)
|
||||
.build()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_dialog_position(app: &tauri::AppHandle, width: f64, _height: f64) -> (f64, f64) {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
if let (Ok(pos), Ok(size)) = (main.outer_position(), main.outer_size()) {
|
||||
let mx = pos.x as f64;
|
||||
let my = pos.y as f64;
|
||||
let mw = size.width as f64;
|
||||
let mh = size.height as f64;
|
||||
let dx = mx + (mw - width) / 2.0;
|
||||
let dy = my + mh;
|
||||
return (dx.max(0.0), dy.max(0.0));
|
||||
}
|
||||
}
|
||||
(0.0, 0.0)
|
||||
}
|
||||
|
||||
fn get_screenshot_window(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(sw) = app.get_webview_window("screenshot") {
|
||||
sw.set_focus()?;
|
||||
return Ok(());
|
||||
}
|
||||
let all_monitors = xcap::Monitor::all()?;
|
||||
let cursor = app.get_webview_window("main").and_then(|w| w.outer_position().ok());
|
||||
let monitor = all_monitors.into_iter().find(|m| {
|
||||
if let (Ok(mx), Ok(my)) = (m.x(), m.y()) {
|
||||
if let (Ok(mw), Ok(mh)) = (m.width(), m.height()) {
|
||||
if let Some(c) = cursor {
|
||||
let cx = c.x as i32;
|
||||
let cy = c.y as i32;
|
||||
return cx >= mx && cx < (mx + mw as i32) && cy >= my && cy < (my + mh as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}).unwrap_or_else(|| {
|
||||
xcap::Monitor::all().ok().and_then(|m| m.into_iter().next())
|
||||
.expect("No monitor found")
|
||||
});
|
||||
|
||||
let scale = monitor.scale_factor().unwrap_or(1.0) as f64;
|
||||
let w = monitor.width().unwrap_or(1920) as f64 / scale;
|
||||
let h = monitor.height().unwrap_or(1080) as f64 / scale;
|
||||
let x = monitor.x().unwrap_or(0) as f64;
|
||||
let y = monitor.y().unwrap_or(0) as f64;
|
||||
|
||||
WebviewWindowBuilder::new(app, "screenshot", WebviewUrl::App("index.html#/screenshot".into()))
|
||||
.title("截图")
|
||||
.inner_size(w, h)
|
||||
.position(x, y)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.build()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ai_grading_assistant_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui-org/nicegui-tauri/main/tauri.conf.schema.json",
|
||||
"productName": "AI阅卷助手",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.ai-grading-assistant.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<template v-if="dialogType">
|
||||
<SettingDialog v-if="dialogType === 'settings'" />
|
||||
<HistoryDialog v-if="dialogType === 'history'" />
|
||||
<TemplateDialog v-if="dialogType === 'templates'" />
|
||||
<RubricEditor v-if="dialogType === 'rubric'" />
|
||||
<ResultDrawer v-if="dialogType === 'result'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, 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 SettingDialog from '@/components/SettingDialog.vue'
|
||||
import HistoryDialog from '@/pages/HistoryDialog.vue'
|
||||
import TemplateDialog from '@/pages/TemplateDialog.vue'
|
||||
import RubricEditor from '@/components/RubricEditor.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const gradingStore = useGradingStore()
|
||||
|
||||
const dialogType = new URLSearchParams(window.location.search).get('dialog')
|
||||
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')
|
||||
const data: any = await invoke('grading_get_data')
|
||||
if (data) {
|
||||
if (data.image) gradingStore.setImage(data.image)
|
||||
if (data.questions) gradingStore.setQuestions(data.questions)
|
||||
if (data.result) gradingStore.setResult(data.result)
|
||||
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(),
|
||||
rubric: () => appStore.openRubricEditor()
|
||||
}
|
||||
actions[dialogType]?.()
|
||||
setTimeout(resizeToContent, 200)
|
||||
} else {
|
||||
await settingsStore.loadSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div ref="toolbarRef" class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }" @mousedown="onDragStart">
|
||||
<div class="toolbar-buttons">
|
||||
<el-button class="toolbar-btn" @click="startScreenshot" :loading="appStore.isGrading">
|
||||
<span class="btn-icon">📷</span>
|
||||
<span class="btn-label">{{ appStore.isGrading ? '阅卷中...' : '截图阅卷' }}</span>
|
||||
</el-button>
|
||||
<el-button class="toolbar-btn" @click="openRubric">
|
||||
<span class="btn-icon">📋</span>
|
||||
<span class="btn-label">评分标准</span>
|
||||
</el-button>
|
||||
<template v-if="!showMenu">
|
||||
<el-button class="toolbar-btn" @click="showMenu = true">
|
||||
<span class="btn-icon">📎</span>
|
||||
<span class="btn-label">其它</span>
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; selectImage()">
|
||||
<span class="btn-icon">📁</span>
|
||||
<span class="btn-label">图片阅卷</span>
|
||||
</el-button>
|
||||
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openTemplates()">
|
||||
<span class="btn-icon">📚</span>
|
||||
<span class="btn-label">模板</span>
|
||||
</el-button>
|
||||
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openHistory()">
|
||||
<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>
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button class="toolbar-btn" @click="openSettings">
|
||||
<span class="btn-icon">⚙</span>
|
||||
<span class="btn-label">设置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="status-bar">{{ statusText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGradingStore } from '@/stores/grading'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { addWatermark } from '@/utils/image'
|
||||
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const gradingStore = useGradingStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const showMenu = ref(false)
|
||||
const toolbarRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const statusText = computed(() => {
|
||||
const questions = gradingStore.questions
|
||||
const idx = gradingStore.currentQuestionIndex
|
||||
return questions?.length ? `第 ${idx + 1}/${questions.length} 题` : ''
|
||||
})
|
||||
|
||||
async function resizeWindow() {
|
||||
await nextTick()
|
||||
if (toolbarRef.value) {
|
||||
try {
|
||||
const w = toolbarRef.value.scrollWidth + 2
|
||||
await getCurrentWindow().setSize(new LogicalSize(Math.max(200, Math.min(w, 1200)), 64))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
watch(showMenu, resizeWindow)
|
||||
|
||||
let unlistenScreenshot: (() => void) | null = null
|
||||
let unlistenRubric: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
resizeWindow()
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenScreenshot = await listen<string>('screenshot-completed', async (event) => {
|
||||
if (!appStore.isGrading) {
|
||||
try {
|
||||
gradingStore.setImage(await addWatermark(event.payload, '学生答案'))
|
||||
await startGrading()
|
||||
} catch (err) {
|
||||
ElMessage.error('水印处理失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
})
|
||||
unlistenRubric = await listen('rubric-updated', async () => {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const data: any = await invoke('rubric_get_data')
|
||||
if (data?.questions?.length) {
|
||||
gradingStore.setQuestions(JSON.parse(JSON.stringify(data.questions)))
|
||||
ElMessage.success(`已应用模板(${data.questions.length} 题)`)
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenScreenshot?.()
|
||||
unlistenRubric?.()
|
||||
})
|
||||
|
||||
async function startScreenshot() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
await invoke('screenshot_start')
|
||||
} catch (err) {
|
||||
ElMessage.error('截图失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectImage() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
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
|
||||
}
|
||||
await startGrading()
|
||||
}
|
||||
}
|
||||
|
||||
async function gradeQuestion(questionIdx: number) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const questions = gradingStore.questions
|
||||
if (!questions.length) { ElMessage.warning('请先在评分标准中添加题目'); return null }
|
||||
const q = questions[Math.min(questionIdx, questions.length - 1)]
|
||||
const result: any = await invoke('ai_grade', {
|
||||
request: {
|
||||
imageBase64: gradingStore.currentImage,
|
||||
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}分`,
|
||||
maxScore: q.maxScore,
|
||||
referenceAnswer: q.referenceAnswer || '',
|
||||
referenceAnswerImage: q.referenceAnswerImage || '',
|
||||
questionTitle: q.questionTitle || '',
|
||||
apiConfig: {
|
||||
apiKey: settingsStore.settings.apiKey,
|
||||
baseUrl: settingsStore.settings.baseUrl,
|
||||
model: settingsStore.settings.model
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function startGrading() {
|
||||
if (!gradingStore.currentImage) { ElMessage.info('请先截图或选择图片'); return }
|
||||
if (!settingsStore.settings.apiKey) {
|
||||
ElMessage.warning('请先在设置中配置 API Key')
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('dialog_open', { dialogType: 'settings' })
|
||||
return
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const rubricData: any = await invoke('rubric_get_data')
|
||||
const questions = rubricData?.questions?.length
|
||||
? JSON.parse(JSON.stringify(rubricData.questions))
|
||||
: [{ questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }]
|
||||
const nextIdx = Math.min(await invoke('rubric_get_next_index') as number ?? 0, questions.length - 1)
|
||||
gradingStore.setQuestions(questions)
|
||||
gradingStore.setCurrentQuestionIndex(nextIdx)
|
||||
appStore.isGrading = true
|
||||
try {
|
||||
const result = await gradeQuestion(nextIdx)
|
||||
if (!result) return
|
||||
gradingStore.setResult(result)
|
||||
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 }
|
||||
}
|
||||
|
||||
function openRubric() {
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'rubric' }))
|
||||
}
|
||||
async function openTemplates() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('dialog_open', { dialogType: 'templates' })
|
||||
}
|
||||
function openHistory() {
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'history' }))
|
||||
}
|
||||
function openSettings() {
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'settings' }))
|
||||
}
|
||||
|
||||
function onDragStart(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement)?.closest('.toolbar-btn')) return
|
||||
e.preventDefault()
|
||||
getCurrentWindow().startDragging()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-toolbar { position: fixed; top: 0; left: 0; width: fit-content; min-width: 200px; z-index: 99999; user-select: none; background: rgba(30,30,30,0.85); backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.3); }
|
||||
.toolbar-buttons { display: flex; gap: 6px; padding: 8px 14px 0; justify-content: center; align-items: center; }
|
||||
.status-bar { padding: 0 14px 6px; font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.6); white-space: nowrap; text-align: center; }
|
||||
.sub-btn { padding: 4px 8px !important; }
|
||||
.collapse-btn { padding: 4px 6px !important; color: rgba(255,255,255,0.5) !important; }
|
||||
.collapse-btn:hover { color: rgba(255,255,255,0.9) !important; }
|
||||
.toolbar-btn { border: none !important; background: transparent !important; color: rgba(255,255,255,0.85) !important; height: 46px !important; padding: 4px 14px !important; display: flex; flex-direction: column; align-items: center; gap: 1px; transition: all 0.2s; cursor: pointer; }
|
||||
.toolbar-btn:hover { background: rgba(255,255,255,0.12) !important; color: #fff !important; }
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="result-drawer" v-show="visible" @click.self="closeDrawer">
|
||||
<div class="drawer-panel">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-title">AI 评分结果</span>
|
||||
<el-button text size="small" @click="closeDrawer">✕</el-button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" v-if="errorMsg" style="padding: 12px 16px;">{{ errorMsg }}</div>
|
||||
<div class="drawer-body" v-if="gradingStore.currentResult">
|
||||
<div class="question-nav" v-if="questions.length > 1">
|
||||
<el-button size="small" @click="prevQuestion">上一题</el-button>
|
||||
<span class="question-indicator">{{ currentIdx + 1 }} / {{ questions.length }}</span>
|
||||
<el-button size="small" @click="nextQuestion">{{ currentIdx === questions.length - 1 ? '回到首题' : '下一题' }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="question-title" v-if="currentQuestion.questionTitle">
|
||||
{{ currentQuestion.questionTitle }}
|
||||
</div>
|
||||
<div class="question-maxscore" v-if="currentQuestion.maxScore">
|
||||
满分:{{ currentQuestion.maxScore }}
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="currentQuestion.rubric">
|
||||
<div class="section-title">当前评分标准</div>
|
||||
<div class="rubric-text">{{ currentQuestion.rubric }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="gradingStore.currentImage">
|
||||
<div class="section-title">学生答案图片</div>
|
||||
<img class="student-image" :src="'data:image/png;base64,' + gradingStore.currentImage" />
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="currentQuestion.referenceAnswer || currentQuestion.referenceAnswerImage">
|
||||
<div class="section-title">参考答案</div>
|
||||
<div class="reference-text" v-if="currentQuestion.referenceAnswer">{{ currentQuestion.referenceAnswer }}</div>
|
||||
<img v-if="currentQuestion.referenceAnswerImage" class="ref-image" :src="currentQuestion.referenceAnswerImage" />
|
||||
</div>
|
||||
|
||||
<div class="result-score">
|
||||
<div class="score-value">{{ gradingStore.currentResult.score }}</div>
|
||||
<div class="score-label">建议分数</div>
|
||||
</div>
|
||||
|
||||
<div class="result-confidence">
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" :style="{ width: (gradingStore.currentResult.confidence * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="confidence-text">可信度:{{ Math.round(gradingStore.currentResult.confidence * 100) }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="gradingStore.currentResult.deductions.length">
|
||||
<div class="section-title">扣分项</div>
|
||||
<div class="deduction-item" v-for="(item, idx) in gradingStore.currentResult.deductions" :key="idx">
|
||||
{{ idx + 1 }}. {{ item }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-title">评语</div>
|
||||
<div class="comment-text">{{ gradingStore.currentResult.comment }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-title">教师评分(可修改)</div>
|
||||
<div class="teacher-score-input">
|
||||
<el-input-number v-model="teacherScore" :min="0" :max="currentQuestion.maxScore" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-title">教师评语</div>
|
||||
<el-input v-model="teacherComment" type="textarea" :rows="2" size="small" placeholder="输入评语(可选)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<el-button size="small" @click="reGrade" :loading="isGrading">重新评分</el-button>
|
||||
<el-button type="primary" size="small" @click="confirmScore">确认评分</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGradingStore } from '@/stores/grading'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import type { QuestionItem } from '@/types'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const gradingStore = useGradingStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const isGrading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const teacherScore = ref(0)
|
||||
const teacherComment = ref('')
|
||||
const questions = ref<QuestionItem[]>([])
|
||||
const currentIdx = ref(0)
|
||||
|
||||
const currentQuestion = computed(() => questions.value[currentIdx.value] || { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' })
|
||||
|
||||
watch(() => appStore.isResultDrawerOpen, (val) => {
|
||||
visible.value = val
|
||||
})
|
||||
|
||||
watch(() => gradingStore.currentResult, (val) => {
|
||||
if (val) {
|
||||
teacherScore.value = val.score
|
||||
teacherComment.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, async (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function closeDrawer() {
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
|
||||
}
|
||||
|
||||
async function gradeAt(idx: number) {
|
||||
if (!gradingStore.currentImage) return
|
||||
isGrading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const q = questions.value[idx]
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const result: any = await invoke('ai_grade', {
|
||||
request: {
|
||||
imageBase64: gradingStore.currentImage,
|
||||
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}分`,
|
||||
maxScore: q.maxScore,
|
||||
referenceAnswer: q.referenceAnswer || '',
|
||||
referenceAnswerImage: q.referenceAnswerImage || '',
|
||||
questionTitle: q.questionTitle || '',
|
||||
apiConfig: {
|
||||
apiKey: settingsStore.settings.apiKey,
|
||||
baseUrl: settingsStore.settings.baseUrl,
|
||||
model: settingsStore.settings.model
|
||||
}
|
||||
}
|
||||
})
|
||||
gradingStore.setResult(result)
|
||||
currentIdx.value = idx
|
||||
gradingStore.setCurrentQuestionIndex(idx)
|
||||
const { invoke: inv } = await import('@tauri-apps/api/core')
|
||||
await inv('grading_store_data', {
|
||||
data: { result, image: gradingStore.currentImage, questions: questions.value, currentQuestionIndex: idx }
|
||||
})
|
||||
ElMessage.success(`第 ${idx + 1} 题评分完成`)
|
||||
} catch (err) {
|
||||
errorMsg.value = (err as Error).message
|
||||
} finally {
|
||||
isGrading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function nextQuestion() {
|
||||
const next = (currentIdx.value + 1) % questions.value.length
|
||||
await saveAndAdvance(next)
|
||||
}
|
||||
|
||||
async function prevQuestion() {
|
||||
const prev = (currentIdx.value - 1 + questions.value.length) % questions.value.length
|
||||
await saveAndAdvance(prev)
|
||||
}
|
||||
|
||||
async function saveAndAdvance(targetIdx: number) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
const result = gradingStore.currentResult
|
||||
const raw = {
|
||||
questionId: 0,
|
||||
imagePath: '',
|
||||
aiScore: result?.score ?? 0,
|
||||
teacherScore: teacherScore.value,
|
||||
confidence: result?.confidence ?? 0,
|
||||
studentAnswer: '',
|
||||
questionTitle: currentQuestion.value.questionTitle,
|
||||
maxScore: currentQuestion.value.maxScore,
|
||||
referenceAnswer: currentQuestion.value.referenceAnswer,
|
||||
rubric: currentQuestion.value.rubric,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function reGrade() {
|
||||
await gradeAt(currentIdx.value)
|
||||
}
|
||||
|
||||
async function confirmScore() {
|
||||
errorMsg.value = ''
|
||||
const result = gradingStore.currentResult
|
||||
if (!result) {
|
||||
errorMsg.value = '暂无评分结果可保存(currentResult 为空)'
|
||||
return
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
const raw = {
|
||||
questionId: 0,
|
||||
imagePath: '',
|
||||
aiScore: result.score,
|
||||
teacherScore: teacherScore.value,
|
||||
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,
|
||||
resultJson: JSON.stringify(result),
|
||||
imageData: gradingStore.currentImage || '',
|
||||
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
|
||||
}
|
||||
await invoke('grading_submit', { data: JSON.parse(JSON.stringify(raw)) })
|
||||
gradingStore.reset()
|
||||
closeDrawer()
|
||||
} catch (err) {
|
||||
errorMsg.value = '保存失败:' + (err as Error).message
|
||||
}
|
||||
}
|
||||
</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); }
|
||||
.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; }
|
||||
.question-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.question-indicator { font-size: 14px; font-weight: 600; color: #409EFF; }
|
||||
.question-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 4px; }
|
||||
.question-maxscore { font-size: 12px; color: #999; margin-bottom: 12px; }
|
||||
.result-score { text-align: center; padding: 16px 0; }
|
||||
.score-value { font-size: 48px; font-weight: 700; color: #409EFF; line-height: 1; }
|
||||
.score-label { font-size: 13px; color: #999; margin-top: 4px; }
|
||||
.result-confidence { margin-bottom: 16px; }
|
||||
.confidence-bar { height: 6px; background: #eee; border-radius: 3px; overflow: hidden; }
|
||||
.confidence-fill { height: 100%; background: linear-gradient(90deg, #67C23A, #409EFF); border-radius: 3px; transition: width 0.3s; }
|
||||
.confidence-text { font-size: 12px; color: #999; margin-top: 4px; text-align: right; }
|
||||
.result-section { margin-bottom: 16px; }
|
||||
.section-title { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
.deduction-item { font-size: 13px; color: #F56C6C; padding: 4px 0; line-height: 1.5; }
|
||||
.comment-text { font-size: 13px; color: #666; line-height: 1.6; background: #f5f7fa; padding: 10px; border-radius: 6px; }
|
||||
.rubric-text { font-size: 13px; color: #333; line-height: 1.6; background: #fff7e6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
|
||||
.reference-text { font-size: 13px; color: #666; line-height: 1.6; background: #f0f9eb; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
|
||||
.ref-image, .student-image { max-width: 100%; max-height: 300px; border-radius: 6px; margin-top: 8px; border: 1px solid #e4e7ed; }
|
||||
.error-message { background: #fef0f0; color: #f56c6c; border: 1px solid #fde2e2; border-radius: 6px; padding: 10px; font-size: 13px; line-height: 1.5; margin-bottom: 12px; }
|
||||
.teacher-score-input { margin-top: 4px; }
|
||||
.drawer-footer { padding: 12px 16px; border-top: 1px solid #eee; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<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="rubric-layout">
|
||||
<div class="tab-list">
|
||||
<div
|
||||
v-for="(q, idx) in questions"
|
||||
:key="idx"
|
||||
class="tab-item"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
@click="activeIndex = idx"
|
||||
>
|
||||
<span>题目 {{ idx + 1 }}</span>
|
||||
<el-button text size="small" @click.stop="removeQuestion(idx)" v-if="questions.length > 1">✕</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addQuestion">+ 添加题目</el-button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" v-if="questions[activeIndex]">
|
||||
<el-form :model="questions[activeIndex]" size="small" label-width="80px">
|
||||
<el-form-item label="题目">
|
||||
<el-input v-model="questions[activeIndex].questionTitle" type="textarea" :rows="2" placeholder="输入题目内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="满分">
|
||||
<el-input-number v-model="questions[activeIndex].maxScore" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参考答案">
|
||||
<el-input v-model="questions[activeIndex].referenceAnswer" type="textarea" :rows="3" placeholder="输入参考答案(可选)" />
|
||||
<div class="ref-image-section">
|
||||
<template v-if="questions[activeIndex].referenceAnswerImage">
|
||||
<div class="ref-image-preview">
|
||||
<img :src="questions[activeIndex].referenceAnswerImage" />
|
||||
<el-button size="small" type="danger" text @click="removeRefImage(activeIndex)">删除图片</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-button size="small" @click="uploadRefImage(activeIndex)">上传参考答案图片</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="评分标准">
|
||||
<el-input v-model="questions[activeIndex].rubric" type="textarea" :rows="6" placeholder="输入评分标准" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-left">
|
||||
<el-button size="small" @click="handleImport">导入</el-button>
|
||||
<el-button size="small" @click="handleExport">导出</el-button>
|
||||
<el-button size="small" @click="showTemplateForm = !showTemplateForm">
|
||||
{{ showTemplateForm ? '取消' : '保存为模板' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<el-button size="small" @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showTemplateForm" class="template-form">
|
||||
<el-form inline size="small">
|
||||
<el-form-item label="模板名称">
|
||||
<el-input v-model="templateName" placeholder="如:期末考试" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程">
|
||||
<el-input v-model="templateCourse" placeholder="如:Java" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" size="small" @click="saveAsTemplate">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGradingStore } from '@/stores/grading'
|
||||
import type { QuestionItem } from '@/types'
|
||||
import { addWatermark } from '@/utils/image'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const gradingStore = useGradingStore()
|
||||
const visible = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
const questions = ref<QuestionItem[]>([])
|
||||
const showTemplateForm = ref(false)
|
||||
const templateName = ref('')
|
||||
const templateCourse = ref('')
|
||||
|
||||
function emptyQuestion(): QuestionItem {
|
||||
return { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }
|
||||
}
|
||||
|
||||
watch(() => appStore.isRubricEditorOpen, async (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const data: any = await invoke('rubric_get_data')
|
||||
if (data?.questions?.length) {
|
||||
questions.value = JSON.parse(JSON.stringify(data.questions))
|
||||
activeIndex.value = data.currentIndex ?? 0
|
||||
} else {
|
||||
questions.value = [emptyQuestion()]
|
||||
activeIndex.value = 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function addQuestion() {
|
||||
questions.value.push(emptyQuestion())
|
||||
activeIndex.value = questions.value.length - 1
|
||||
}
|
||||
|
||||
function removeQuestion(idx: number) {
|
||||
questions.value.splice(idx, 1)
|
||||
if (activeIndex.value >= questions.value.length) {
|
||||
activeIndex.value = questions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
gradingStore.setQuestions(JSON.parse(JSON.stringify(questions.value)))
|
||||
await invoke('rubric_store_data', {
|
||||
data: {
|
||||
questions: JSON.parse(JSON.stringify(questions.value)),
|
||||
currentIndex: 0
|
||||
}
|
||||
})
|
||||
await invoke('rubric_reset_next_index')
|
||||
ElMessage.success('评分标准已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
await invoke('rubric_export_json')
|
||||
ElMessage.success('导出成功')
|
||||
} catch (err) {
|
||||
ElMessage.error('导出失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
const data: any = await invoke('rubric_import_json')
|
||||
if (data?.questions?.length) {
|
||||
questions.value = JSON.parse(JSON.stringify(data.questions))
|
||||
activeIndex.value = 0
|
||||
ElMessage.success(`已导入 ${data.questions.length} 题`)
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('导入失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsTemplate() {
|
||||
if (!templateName.value || !templateCourse.value) {
|
||||
ElMessage.warning('请填写模板名称和课程')
|
||||
return
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
const ok = await invoke('template_save', {
|
||||
data: {
|
||||
name: templateName.value,
|
||||
course: templateCourse.value,
|
||||
questions: JSON.parse(JSON.stringify(questions.value))
|
||||
}
|
||||
})
|
||||
if (ok) {
|
||||
ElMessage.success('模板已保存')
|
||||
templateName.value = ''
|
||||
templateCourse.value = ''
|
||||
showTemplateForm.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('保存模板失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
|
||||
}
|
||||
|
||||
async function uploadRefImage(idx: number) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const data: any = await invoke('dialog_open_image')
|
||||
if (data) {
|
||||
const mime = data.mime || 'image/png'
|
||||
const base64 = await addWatermark(data.base64, '参考答案')
|
||||
questions.value[idx].referenceAnswerImage = `data:image/${mime};base64,${base64}`
|
||||
}
|
||||
}
|
||||
|
||||
function removeRefImage(idx: number) {
|
||||
delete questions.value[idx].referenceAnswerImage
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-header {
|
||||
display: flex; align-items: center; padding: 0;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.rubric-layout {
|
||||
display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0;
|
||||
}
|
||||
.tab-list {
|
||||
display: flex; gap: 4px; flex-wrap: wrap; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.tab-item {
|
||||
display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 4px;
|
||||
font-size: 13px; cursor: pointer; background: #f5f7fa; border: 1px solid #e4e7ed;
|
||||
}
|
||||
.tab-item.active { background: #409EFF; color: #fff; border-color: #409EFF; }
|
||||
.tab-content {
|
||||
border: 1px solid #e4e7ed; border-radius: 6px; padding: 16px;
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
.footer-left { display: flex; gap: 8px; }
|
||||
.footer-right { display: flex; gap: 8px; }
|
||||
.ref-image-section { margin-top: 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.ref-image-preview { display: flex; align-items: center; gap: 8px; }
|
||||
.ref-image-preview img { max-height: 120px; max-width: 200px; border-radius: 4px; border: 1px solid #e4e7ed; }
|
||||
.template-form { padding: 8px 16px; border-top: 1px solid #eee; background: #fafafa; }
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
|
||||
}
|
||||
:deep(.el-dialog__footer) {
|
||||
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="screenshot-overlay" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp" :class="{ 'has-selection': hasSelection }">
|
||||
<canvas ref="canvasRef" class="screenshot-canvas"></canvas>
|
||||
<div v-if="!hasSelection" class="screenshot-tip">拖动选择截图区域 · Esc取消</div>
|
||||
<div v-else class="screenshot-actions" :style="actionsStyle" @mousedown.stop @mouseup.stop>
|
||||
<button class="action-btn confirm" @click="confirmCapture">✓ 确认</button>
|
||||
<button class="action-btn cancel" @click="cancel">✕ 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const state = { isSelecting: false, startX: 0, startY: 0, endX: 0, endY: 0 }
|
||||
const hasSelection = ref(false)
|
||||
const isCapturing = ref(false)
|
||||
|
||||
const actionsStyle = computed(() => {
|
||||
if (!hasSelection.value) return {}
|
||||
const x = Math.min(state.startX, state.endX)
|
||||
const y = Math.min(state.startY, state.endY)
|
||||
const w = Math.abs(state.endX - state.startX)
|
||||
const h = Math.abs(state.endY - state.startY)
|
||||
const panelW = 160; const gap = 8
|
||||
let left = x + w - panelW; let top = y + h + gap
|
||||
if (left < 4) left = 4
|
||||
if (top + 40 > window.innerHeight) top = y - 40 - gap
|
||||
return { left: left + 'px', top: top + 'px', transform: 'none', bottom: 'auto' }
|
||||
})
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
hasSelection.value = false; state.isSelecting = true
|
||||
state.startX = e.clientX; state.startY = e.clientY
|
||||
state.endX = e.clientX; state.endY = e.clientY
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!state.isSelecting) return
|
||||
state.endX = e.clientX; state.endY = e.clientY; drawOverlay()
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!state.isSelecting) return; state.isSelecting = false
|
||||
const w = Math.abs(state.endX - state.startX); const h = Math.abs(state.endY - state.startY)
|
||||
if (w < 10 || h < 10) return
|
||||
hasSelection.value = true; drawOverlay()
|
||||
}
|
||||
async function confirmCapture() {
|
||||
const x = Math.min(state.startX, state.endX); const y = Math.min(state.startY, state.endY)
|
||||
const width = Math.abs(state.endX - state.startX); const height = Math.abs(state.endY - state.startY)
|
||||
if (width < 10 || height < 10) return; if (isCapturing.value) return; isCapturing.value = true
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('screenshot_capture', { region: { x, y, width, height } }).catch(() => {})
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
try { await getCurrentWindow().close() } catch {}
|
||||
}
|
||||
async function cancel() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('screenshot_cancel').catch(() => {})
|
||||
isCapturing.value = false
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
try { await getCurrentWindow().close() } catch {}
|
||||
}
|
||||
function drawOverlay() {
|
||||
const canvas = canvasRef.value; if (!canvas) return
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) return
|
||||
canvas.width = window.innerWidth; canvas.height = window.innerHeight
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const x = Math.min(state.startX, state.endX); const y = Math.min(state.startY, state.endY)
|
||||
const w = Math.abs(state.endX - state.startX); const h = Math.abs(state.endY - state.startY)
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'; ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.clearRect(x, y, w, h)
|
||||
ctx.strokeStyle = '#409EFF'; ctx.lineWidth = 2.5; ctx.strokeRect(x, y, w, h)
|
||||
ctx.fillStyle = 'rgba(64, 158, 255, 0.08)'; ctx.fillRect(x, y, w, h)
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; ctx.font = '13px sans-serif'
|
||||
ctx.fillText(`${w} × ${h}`, x + 6, y - 8)
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel() }
|
||||
if (e.key === 'Enter' && hasSelection.value) { e.preventDefault(); e.stopPropagation(); confirmCapture() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screenshot-overlay { position: fixed; inset: 0; z-index: 99999; cursor: crosshair; }
|
||||
.screenshot-overlay.has-selection { cursor: default; }
|
||||
.screenshot-canvas { position: fixed; inset: 0; width: 100%; height: 100%; }
|
||||
.screenshot-tip { position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); color: #fff; padding: 8px 16px; border-radius: 6px; font-size: 13px; pointer-events: none; }
|
||||
.screenshot-actions { position: fixed; display: flex; gap: 10px; pointer-events: auto; }
|
||||
.action-btn { border: none; padding: 8px 20px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
|
||||
.action-btn.confirm { background: #409EFF; color: #fff; }
|
||||
.action-btn.confirm:hover { background: #66b1ff; }
|
||||
.action-btn.cancel { background: rgba(0,0,0,0.6); color: #fff; }
|
||||
.action-btn.cancel:hover { background: rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" fullscreen :modal="false" :close-on-click-modal="false" @close="handleClose">
|
||||
<template #header>
|
||||
<div class="dialog-header" data-tauri-drag-region>
|
||||
<span class="dialog-title">设置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Base URL">
|
||||
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型">
|
||||
<el-input v-model="form.model" placeholder="手动输入模型名称" />
|
||||
<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<el-tag
|
||||
v-for="m in visionModels"
|
||||
:key="m"
|
||||
size="small"
|
||||
style="cursor:pointer"
|
||||
@click="form.model = m"
|
||||
>{{ m }}</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="快捷键">
|
||||
<el-input v-model="form.shortcut" placeholder="Alt+Q" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.timeout" :min="5" :max="300" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主题">
|
||||
<el-select v-model="form.theme" style="width: 100%">
|
||||
<el-option label="浅色" value="light" />
|
||||
<el-option label="深色" value="dark" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="字体大小">
|
||||
<el-input-number v-model="form.fontSize" :min="12" :max="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="代理">
|
||||
<el-input v-model="form.proxy" placeholder="可选,如 http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="截图保存路径">
|
||||
<div style="display:flex;gap:8px;width:100%">
|
||||
<el-input v-model="form.screenshotSavePath" placeholder="留空不自动保存截图" />
|
||||
<el-button @click="selectDir">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button size="small" @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" size="small" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const visionModels = [
|
||||
'Qwen/Qwen2.5-32B-Instruct',
|
||||
'Qwen/Qwen3.6-35B-A3B',
|
||||
'gpt-4o',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'gemini-2.0-flash-exp',
|
||||
'deepseek-vl2'
|
||||
]
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
watch(() => appStore.isSettingDialogOpen, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
form.value = { ...settingsStore.settings }
|
||||
}
|
||||
})
|
||||
|
||||
const form = ref({ ...settingsStore.settings })
|
||||
|
||||
function handleClose() {
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
|
||||
}
|
||||
|
||||
async function selectDir() {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const dir = await open({ directory: true })
|
||||
if (dir) form.value.screenshotSavePath = dir
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await settingsStore.saveSettings(JSON.parse(JSON.stringify(form.value)))
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().close()
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-header {
|
||||
display: flex; align-items: center; padding: 0;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.settings-body {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
|
||||
}
|
||||
:deep(.el-dialog__footer) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="历史记录" width="800px" fullscreen :modal="false" @close="handleClose">
|
||||
<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" />
|
||||
<el-table-column prop="teacher_score" label="教师分" width="70" />
|
||||
<el-table-column prop="confidence" label="可信度" width="70">
|
||||
<template #default="{ row }">
|
||||
{{ Math.round(row.confidence * 100) }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="question_title" label="题目" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="150" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" type="primary" @click="viewResult(row)">查看</el-button>
|
||||
<el-button text size="small" type="danger" @click="deleteRecord(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
<el-button size="small" @click="refresh">刷新</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div v-if="detailVisible" class="history-result-overlay" @click.self="detailVisible = false">
|
||||
<div class="history-result-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">评分详情</span>
|
||||
<el-button text size="small" @click="detailVisible = false">✕</el-button>
|
||||
</div>
|
||||
<div class="panel-body" v-if="detail">
|
||||
<div class="question-title" v-if="detail.question_title">
|
||||
{{ detail.question_title }}
|
||||
</div>
|
||||
<div class="question-maxscore" v-if="detail.max_score">
|
||||
满分:{{ detail.max_score }}
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.rubric">
|
||||
<div class="section-title">当前评分标准</div>
|
||||
<div class="rubric-text">{{ detail.rubric }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.image_data">
|
||||
<div class="section-title">学生答案图片</div>
|
||||
<img class="result-image" :src="'data:image/png;base64,' + detail.image_data" />
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.reference_answer || detail.reference_answer_image">
|
||||
<div class="section-title">参考答案</div>
|
||||
<div class="reference-text" v-if="detail.reference_answer">{{ detail.reference_answer }}</div>
|
||||
<img v-if="detail.reference_answer_image" class="result-image" :src="detail.reference_answer_image" />
|
||||
</div>
|
||||
|
||||
<div class="result-score">
|
||||
<div class="score-value">{{ detail.ai_score }}</div>
|
||||
<div class="score-label">建议分数</div>
|
||||
</div>
|
||||
|
||||
<div class="result-confidence">
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" :style="{ width: Math.round(detail.confidence * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="confidence-text">可信度:{{ Math.round(detail.confidence * 100) }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="deductionsList.length">
|
||||
<div class="section-title">扣分项</div>
|
||||
<div class="deduction-item" v-for="(item, idx) in deductionsList" :key="idx">
|
||||
{{ Number(idx) + 1 }}. {{ item }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="detail.comment">
|
||||
<div class="section-title">评语</div>
|
||||
<div class="comment-text">{{ detail.comment }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-title">教师评分</div>
|
||||
<div class="teacher-score-display">{{ detail.teacher_score }} / {{ detail.max_score || 100 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { HistoryRecord } from '@/types'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const records = ref<HistoryRecord[]>([])
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<HistoryRecord | null>(null)
|
||||
const deductionsList = computed(() => {
|
||||
if (!detail.value?.deductions) return []
|
||||
try { return JSON.parse(detail.value.deductions) } catch { return [] }
|
||||
})
|
||||
|
||||
watch(() => appStore.isHistoryDialogOpen, (val) => {
|
||||
visible.value = val
|
||||
if (val) refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
records.value = await invoke('history_list')
|
||||
} catch {
|
||||
records.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewResult(row: HistoryRecord) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
const full: any = await invoke('history_get_by_id', { id: row.id })
|
||||
if (full) {
|
||||
detail.value = full
|
||||
detailVisible.value = true
|
||||
} else {
|
||||
ElMessage.warning('未找到记录详情')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('加载详情失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该记录?', '提示')
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('history_delete', { id })
|
||||
ElMessage.success('已删除')
|
||||
refresh()
|
||||
} catch { /* cancelled */ }
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-result-overlay { position: fixed; inset: 0; z-index: 99998; background: rgba(0, 0, 0, 0.3); display: flex; justify-content: flex-end; }
|
||||
.history-result-panel { width: 480px; height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid #eee; font-size: 15px; font-weight: 600; }
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.panel-footer { padding: 12px 16px; border-top: 1px solid #eee; display: flex; justify-content: flex-end; }
|
||||
.question-title { font-size: 14px; font-weight: 600; color: #333; line-height: 1.5; }
|
||||
.question-maxscore { font-size: 12px; color: #909399; }
|
||||
.result-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.section-title { font-size: 12px; font-weight: 600; color: #909399; }
|
||||
.rubric-text { font-size: 13px; color: #333; line-height: 1.6; background: #fff7e6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
|
||||
.reference-text { font-size: 13px; color: #666; line-height: 1.6; background: #f0f9eb; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
|
||||
.result-image { max-width: 100%; max-height: 300px; border-radius: 6px; border: 1px solid #e4e7ed; }
|
||||
.result-score { text-align: center; padding: 12px 0; }
|
||||
.score-value { font-size: 42px; font-weight: 700; color: #409EFF; line-height: 1; }
|
||||
.score-label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.result-confidence { display: flex; flex-direction: column; gap: 4px; }
|
||||
.confidence-bar { height: 6px; background: #e4e7ed; border-radius: 3px; overflow: hidden; }
|
||||
.confidence-fill { height: 100%; background: #67c23a; border-radius: 3px; transition: width 0.3s; }
|
||||
.confidence-text { font-size: 11px; color: #909399; text-align: right; }
|
||||
.deduction-item { font-size: 13px; color: #f56c6c; line-height: 1.6; padding: 2px 0; }
|
||||
.comment-text { font-size: 13px; color: #333; line-height: 1.6; background: #f5f7fa; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
|
||||
.teacher-score-display { font-size: 16px; font-weight: 600; color: #409EFF; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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="template-body">
|
||||
<el-table :data="templates" size="small" style="width: 100%" height="100%">
|
||||
<el-table-column prop="id" label="ID" width="50" />
|
||||
<el-table-column prop="name" label="考试名称" min-width="130" />
|
||||
<el-table-column prop="course" label="课程" min-width="100" />
|
||||
<el-table-column label="题目数" width="70">
|
||||
<template #default="{ row }">{{ row.questions?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="150" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" :disabled="!row.questions?.length" @click="selectTemplate(row)">使用</el-button>
|
||||
<el-button text size="small" type="danger" @click="deleteTemplate(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-left">
|
||||
<el-button size="small" @click="exportTemplates">导出</el-button>
|
||||
<el-button size="small" @click="importTemplates">导入</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { TemplateItem } from '@/types'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const visible = ref(false)
|
||||
const templates = ref<TemplateItem[]>([])
|
||||
|
||||
watch(() => appStore.isTemplateDialogOpen, (val) => {
|
||||
visible.value = val
|
||||
if (val) loadTemplates()
|
||||
})
|
||||
|
||||
async function loadTemplates() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
templates.value = await invoke('template_list')
|
||||
}
|
||||
|
||||
async function selectTemplate(tpl: TemplateItem) {
|
||||
if (!tpl.questions?.length) {
|
||||
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 {
|
||||
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('导出失败或已取消')
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-header {
|
||||
display: flex; align-items: center; padding: 0;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.template-body {
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
|
||||
}
|
||||
:deep(.el-dialog__footer) {
|
||||
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.footer-left {
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import FloatingToolbar from '@/components/FloatingToolbar.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'toolbar',
|
||||
component: FloatingToolbar
|
||||
},
|
||||
{
|
||||
path: '/screenshot',
|
||||
name: 'screenshot',
|
||||
component: () => import('@/components/ScreenshotOverlay.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const isToolbarVisible = ref(true)
|
||||
const isResultDrawerOpen = ref(false)
|
||||
const isSettingDialogOpen = ref(false)
|
||||
const isHistoryDialogOpen = ref(false)
|
||||
const isTemplateDialogOpen = ref(false)
|
||||
const isRubricEditorOpen = ref(false)
|
||||
const isGrading = ref(false)
|
||||
|
||||
function toggleToolbar() { isToolbarVisible.value = !isToolbarVisible.value }
|
||||
function openResultDrawer() { isResultDrawerOpen.value = true }
|
||||
function closeResultDrawer() { isResultDrawerOpen.value = false }
|
||||
function openSettingDialog() { isSettingDialogOpen.value = true }
|
||||
function closeSettingDialog() { isSettingDialogOpen.value = false }
|
||||
function openHistoryDialog() { isHistoryDialogOpen.value = true }
|
||||
function closeHistoryDialog() { isHistoryDialogOpen.value = false }
|
||||
function openTemplateDialog() { isTemplateDialogOpen.value = true }
|
||||
function closeTemplateDialog() { isTemplateDialogOpen.value = false }
|
||||
function openRubricEditor() { isRubricEditorOpen.value = true }
|
||||
function closeRubricEditor() { isRubricEditorOpen.value = false }
|
||||
|
||||
return {
|
||||
isToolbarVisible, isResultDrawerOpen, isSettingDialogOpen, isHistoryDialogOpen,
|
||||
isTemplateDialogOpen, isRubricEditorOpen, isGrading,
|
||||
toggleToolbar, openResultDrawer, closeResultDrawer, openSettingDialog, closeSettingDialog,
|
||||
openHistoryDialog, closeHistoryDialog, openTemplateDialog, closeTemplateDialog,
|
||||
openRubricEditor, closeRubricEditor
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { GradeResult, QuestionItem } from '@/types'
|
||||
|
||||
export const useGradingStore = defineStore('grading', () => {
|
||||
const currentImage = ref<string | null>(null)
|
||||
const currentResult = ref<GradeResult | null>(null)
|
||||
const questions = ref<QuestionItem[]>([])
|
||||
const currentQuestionIndex = ref(0)
|
||||
const rubric = ref('')
|
||||
const questionTitle = ref('')
|
||||
const maxScore = ref(100)
|
||||
const referenceAnswer = ref('')
|
||||
const teacherScore = ref<number | null>(null)
|
||||
const teacherComment = ref('')
|
||||
|
||||
function setImage(base64: string) { currentImage.value = base64 }
|
||||
function setResult(result: GradeResult) { currentResult.value = result }
|
||||
function setRubric(text: string) { rubric.value = text }
|
||||
function setQuestionTitle(text: string) { questionTitle.value = text }
|
||||
function setMaxScore(val: number) { maxScore.value = val }
|
||||
function setReferenceAnswer(val: string) { referenceAnswer.value = val }
|
||||
function setQuestions(list: QuestionItem[]) { questions.value = list }
|
||||
function setCurrentQuestionIndex(idx: number) { currentQuestionIndex.value = idx }
|
||||
|
||||
function reset() {
|
||||
currentImage.value = null
|
||||
currentResult.value = null
|
||||
teacherScore.value = null
|
||||
teacherComment.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
currentImage, currentResult, questions, currentQuestionIndex, rubric,
|
||||
questionTitle, maxScore, referenceAnswer, teacherScore, teacherComment,
|
||||
setImage, setResult, setRubric, setQuestionTitle, setMaxScore, setReferenceAnswer,
|
||||
setQuestions, setCurrentQuestionIndex, reset
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { AppSettings } from '@/types'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<AppSettings>({
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||
model: '',
|
||||
shortcut: 'Alt+Q',
|
||||
theme: 'light',
|
||||
fontSize: 14,
|
||||
proxy: '',
|
||||
timeout: 120,
|
||||
screenshotSavePath: ''
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const result: AppSettings = await invoke('settings_get')
|
||||
if (result) {
|
||||
settings.value = { ...settings.value, ...result }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
return { settings, loadSettings, saveSettings }
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html[data-window="win-main"] { background: #1e1e1e; }
|
||||
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; }
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 3px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface QuestionItem {
|
||||
questionTitle: string
|
||||
maxScore: number
|
||||
referenceAnswer: string
|
||||
rubric: string
|
||||
referenceAnswerImage?: string
|
||||
}
|
||||
|
||||
export interface GradeResult {
|
||||
score: number
|
||||
confidence: number
|
||||
deductions: string[]
|
||||
comment: string
|
||||
commit?: string
|
||||
}
|
||||
|
||||
export interface ApiConfig {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
shortcut: string
|
||||
theme: string
|
||||
fontSize: number
|
||||
proxy: string
|
||||
timeout: number
|
||||
screenshotSavePath: string
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: number
|
||||
question_id: number
|
||||
image_path: string
|
||||
student_answer: string
|
||||
ai_score: number
|
||||
teacher_score: number
|
||||
confidence: number
|
||||
created_at: string
|
||||
question_title?: string
|
||||
max_score?: number
|
||||
reference_answer?: string
|
||||
rubric?: string
|
||||
deductions?: string
|
||||
comment?: string
|
||||
result_json?: string
|
||||
image_data?: string
|
||||
reference_answer_image?: string
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
id: number
|
||||
name: string
|
||||
course: string
|
||||
questions: QuestionItem[]
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function addWatermark(base64: string, text: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
let w = img.width; let h = img.height; const MAX = 2048; let scale = 1
|
||||
if (w > MAX || h > MAX) { scale = Math.min(MAX / w, MAX / h, 1); w = Math.round(w * scale); h = Math.round(h * scale) }
|
||||
const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return reject(new Error('Canvas 2D context not available'))
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
ctx.font = `bold ${Math.max(28, Math.round(w / 20))}px sans-serif`
|
||||
ctx.fillStyle = 'rgba(255, 0, 0, 0.85)'; ctx.textBaseline = 'top'
|
||||
const padding = Math.max(8, Math.round(w / 60))
|
||||
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.src = `data:image/png;base64,${base64}`
|
||||
})
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari14',
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user