Initial commit: Update project structure and add new features

This commit is contained in:
2026-05-10 19:47:27 +08:00
parent b4fa80ba82
commit 675539eada
64 changed files with 6777 additions and 1133 deletions
+1
View File
@@ -0,0 +1 @@
v24.15.0
-7
View File
@@ -1,7 +0,0 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+7 -4
View File
@@ -11,13 +11,16 @@
"tdev": "tauri dev"
},
"dependencies": {
"@lucide/vue": "^1.6.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "~2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "~2.3.2",
"daisyui": "^5.5.19",
"lucide-vue-next": "^0.577.0",
"tailwindcss": "^4.2.1",
"vue": "^3.5.13",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"vue3-hot-toast": "^0.0.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
@@ -25,8 +28,8 @@
"@types/node": "^25.3.5",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.0.18",
"vite": "^8.0.0",
"vitest": "^4.1.0",
"vue-tsc": "^2.1.10"
}
}
+510 -369
View File
File diff suppressed because it is too large Load Diff
+2943 -31
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -22,4 +22,10 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-sql = { version = "2", features = ['sqlite'] }
tauri-plugin-notification = "2"
iroh = { version = "0.97", features = ["address-lookup-mdns"] }
iroh-tickets = "0.4"
tokio = "1.50.0"
anyhow = "1.0.102"
chrono = "0.4"
+161
View File
@@ -0,0 +1,161 @@
# LocalFinder 实现说明
## 📋 概述
已成功构建 `LocalFinder` 结构的基础框架,用于 Iroh 的局域网设备发现功能。由于 Iroh v0.97 API 发生重大变更,当前实现为占位符版本。
## ✅ 已完成的工作
### 1. 文件结构
创建了以下文件:
- **`src/iroh/local_find.rs`** - LocalFinder 核心实现
- **`src/iroh/mod.rs`** - Iroh 模块导出
- **Tauri Commands** - 在 `handlers/tauri_handlers.rs` 中添加了 5 个新的命令处理器
### 2. LocalFinder 结构
```rust
pub struct LocalFinder {
node_id: String,
}
```
#### 已实现的方法:
- `new()` - 创建局域网发现管理器(占位符)
- `local_id()` - 获取本地节点 ID
### 3. Tauri Commands
已添加以下命令供前端调用:
1. **`initialize_local_finder()`** - 初始化局域网发现管理器
2. **`start_discovery()`** - 开始设备发现(占位符)
3. **`stop_discovery()`** - 停止设备发现(占位符)
4. **`get_local_node_id()`** - 获取本地节点 ID
5. **`get_discovered_peers()`** - 获取发现的设备列表(占位符)
### 4. 集成到应用
- ✅ 更新了 `lib.rs` 导出 `LocalFinder`
- ✅ 在 Tauri 应用中注册了 `LocalFinder` 状态管理
- ✅ 将所有新 commands 添加到 invoke_handler
## ⚠️ 当前限制
### Iroh API 变更问题
Iroh v0.97 的 API 与早期版本有很大不同,主要问题包括:
1. **`Endpoint::builder()`** - 现在需要 `Preset` trait 参数
2. **`SecretKey::generate()`** - 需要特定类型的 RNG
3. **`MdnsAddressLookup`** - MDNS 发现 API 已变更
4. **依赖冲突** - `rand``rand_core` 版本兼容性问题
### 占位符实现
当前代码使用占位符实现,仅用于演示结构和接口设计。
## 🔧 下一步工作
### 需要查阅的文档
1. **Iroh v0.97 官方文档** - https://docs.rs/iroh/latest/iroh/
2. **Iroh GitHub 仓库** - https://github.com/n0-computer/iroh
3. **Iroh Examples** - 查看最新的示例代码
### 待实现的功能
1. **正确的 Endpoint 初始化**
```rust
// 需要找到正确的方式
let endpoint = Endpoint::builder(preset).bind().await?;
```
2. **SecretKey 生成**
```rust
// 需要使用正确的 RNG
let secret_key = SecretKey::generate(/* correct rng */);
```
3. **MDNS 发现集成**
```rust
// 需要查阅新的 API
let mdns = MdnsAddressLookup::new()?;
endpoint.discovery().add(mdns)?;
```
4. **设备连接和管理**
- `connect_to_peer()` - 连接到发现的设备
- `disconnect_peer()` - 断开连接
- `get_known_peers()` - 获取已知设备列表
## 📝 使用示例(当前占位符)
### Rust 代码
```rust
use crate::iroh::LocalFinder;
// 创建 LocalFinder
let finder = LocalFinder::new().await?;
// 获取节点 ID
let node_id = finder.local_id();
println!("Local node ID: {}", node_id);
```
### TypeScript/JavaScript 调用(前端)
```typescript
// 初始化
const nodeId = await invoke('initialize_local_finder');
console.log(nodeId);
// 获取本地节点 ID
const localId = await invoke('get_local_node_id');
console.log(localId);
// 开始发现(占位符)
await invoke('start_discovery', { durationSecs: 60 });
// 获取发现的设备(占位符)
const peers = await invoke('get_discovered_peers');
console.log(peers);
```
## 🎯 建议
由于 Iroh API 频繁变更,建议:
1. **暂时移除 Iroh 依赖** - 避免编译错误
2. **使用条件编译** - 仅在需要时启用 Iroh 功能
3. **关注 Iroh 更新** - 订阅 Iroh 的 release notes
4. **考虑替代方案** - 如果 Iroh 不稳定,可以考虑其他 P2P 库
## 📦 依赖配置
当前 `Cargo.toml` 中的相关依赖:
```toml
[dependencies]
iroh = { version = "0.97", features = ["address-lookup-mdns"] }
iroh-tickets = "0.4"
tokio = "1.50.0"
anyhow = "1.0.102"
```
## 📊 项目状态
-**LocalFinder 结构** - 完成
-**Tauri 集成** - 完成
-**Commands 实现** - 完成(占位符)
- ⚠️ **实际功能** - 等待 Iroh API 研究
---
**创建时间**: 2026-04-03
**最后更新**: 2026-04-03
**状态**: 占位符实现,等待 Iroh API 研究
Binary file not shown.
+6 -2
View File
@@ -2,9 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"sql:default",
"notification:default"
]
}
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
use tauri_plugin_sql::{Migration, MigrationKind};
/// 获取所有数据库迁移
pub fn get_migrations() -> Vec<Migration> {
vec![
// 版本 1: 创建初始表
Migration {
version: 1,
description: "create_initial_tables",
sql: r#"
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 工作流表
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT DEFAULT 'draft',
creator TEXT NOT NULL,
definition TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 流程节点表
CREATE TABLE IF NOT EXISTS workflow_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
node_type TEXT NOT NULL,
position_x REAL NOT NULL,
position_y REAL NOT NULL,
data TEXT,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
-- 流程边表
CREATE TABLE IF NOT EXISTS workflow_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
source TEXT NOT NULL,
target TEXT NOT NULL,
label TEXT,
data TEXT,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
-- 消息表
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
is_read BOOLEAN DEFAULT FALSE,
workflow_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE SET NULL
);
"#,
kind: MigrationKind::Up,
},
]
}
+1
View File
@@ -0,0 +1 @@
pub mod migrations;
+3
View File
@@ -0,0 +1,3 @@
pub mod tauri_handlers;
pub use tauri_handlers::TauriMessageHandler;
+159
View File
@@ -0,0 +1,159 @@
use crate::router::MessageRouter;
use crate::iroh::LocalFinder;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct TauriMessageHandler {
#[allow(dead_code)]
router: Arc<Mutex<MessageRouter>>,
}
impl TauriMessageHandler {
pub fn new(router: MessageRouter) -> Self {
TauriMessageHandler {
router: Arc::new(Mutex::new(router)),
}
}
}
#[tauri::command]
pub async fn send_message(state: tauri::State<'_, Arc<Mutex<MessageRouter>>>, message: Value) -> Result<String, String> {
let router = state.lock().await;
match router.send(message).await {
Ok(_) => Ok("Message sent successfully".to_string()),
Err(e) => Err(format!("Failed to send message: {}", e)),
}
}
#[tauri::command]
pub fn get_supported_message_types() -> Result<Vec<String>, String> {
Ok(vec!["text".to_string(), "file".to_string()])
}
/// 初始化局域网发现
#[tauri::command]
pub async fn initialize_local_finder(
state: tauri::State<'_, Arc<Mutex<Option<LocalFinder>>>>,
) -> Result<String, String> {
let mut finder_opt = state.lock().await;
if finder_opt.is_some() {
return Ok("Local finder already initialized".to_string());
}
match LocalFinder::new().await {
Ok(finder) => {
let node_id = finder.local_id();
*finder_opt = Some(finder);
Ok(format!("Local finder initialized successfully. Node ID: {}", node_id))
}
Err(e) => Err(format!("Failed to initialize local finder: {}", e)),
}
}
/// 开始设备发现
#[tauri::command]
pub async fn start_discovery(
_state: tauri::State<'_, Arc<Mutex<Option<LocalFinder>>>>,
_duration_secs: u64,
) -> Result<String, String> {
Ok("Discovery functionality will be implemented in future versions".to_string())
}
/// 停止设备发现
#[tauri::command]
pub async fn stop_discovery(
_state: tauri::State<'_, Arc<Mutex<Option<LocalFinder>>>>,
) -> Result<String, String> {
Ok("Stop discovery functionality will be implemented in future versions".to_string())
}
/// 获取本地节点 ID
#[tauri::command]
pub async fn get_local_node_id(
state: tauri::State<'_, Arc<Mutex<Option<LocalFinder>>>>,
) -> Result<String, String> {
let finder_opt = state.lock().await;
if let Some(finder) = finder_opt.as_ref() {
Ok(finder.local_id())
} else {
Err("Local finder not initialized".to_string())
}
}
/// 获取发现的设备列表
#[tauri::command]
pub async fn get_discovered_peers(
state: tauri::State<'_, Arc<Mutex<Option<LocalFinder>>>>,
) -> Result<String, String> {
let finder_opt = state.lock().await;
if let Some(finder) = finder_opt.as_ref() {
Ok(format!("Local node ID: {}", finder.local_id()))
} else {
Err("Local finder not initialized".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::TextMessagePlugin;
use serde_json::json;
#[tokio::test]
async fn test_send_message_success() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
let handler = TauriMessageHandler::new(router);
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Test"
});
let result = handler.send_message(message).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Message sent successfully");
}
#[tokio::test]
async fn test_send_message_failure() {
// 不设置 iroh_sender,导致发送失败
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.build();
let handler = TauriMessageHandler::new(router);
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Test"
});
let result = handler.send_message(message).await;
assert!(result.is_err());
}
#[test]
fn test_get_supported_message_types() {
let router = MessageRouter::builder().build();
let handler = TauriMessageHandler::new(router);
let result = handler.get_supported_message_types();
assert!(result.is_ok());
let types = result.unwrap();
assert!(types.contains(&"text".to_string()));
assert!(types.contains(&"file".to_string()));
}
}
+42
View File
@@ -0,0 +1,42 @@
/// 局域网发现管理器(占位符实现)
///
/// TODO: 需要查阅最新的 Iroh 文档来正确实现
/// Iroh v0.97 的 API 与早期版本有很大不同
pub struct LocalFinder {
node_id: String,
}
impl LocalFinder {
/// 创建新的局域网发现管理器
pub async fn new() -> anyhow::Result<Self> {
// 占位符实现 - 仅用于演示结构
Ok(LocalFinder {
node_id: "placeholder-node-id".to_string(),
})
}
/// 获取本地节点 ID
pub fn local_id(&self) -> String {
self.node_id.clone()
}
}
impl Drop for LocalFinder {
fn drop(&mut self) {
// 清理资源(占位符)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_local_finder_creation() {
let finder = LocalFinder::new().await;
assert!(finder.is_ok());
let finder = finder.unwrap();
assert!(!finder.local_id().is_empty());
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod local_find;
pub use local_find::LocalFinder;
+58 -25
View File
@@ -1,35 +1,68 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
// #[tauri::command]
// fn something(state: tauri::State<String>) -> String {
// state.inner().clone()
// }
// #[cfg(test)]
// mod tests {
// use super::something;
// use tauri::Manager;
// #[test]
// pub fn test_something() {
// let app = tauri::test::mock_app();
// app.manage("something".to_string());
// assert_eq!(&something(app.state::<String>()), "something");
// }
// }
mod db;
mod iroh;
mod plugins;
mod router;
mod handlers;
pub use plugins::{MessagePlugin, PluginResult};
pub use router::MessageRouter;
pub use handlers::TauriMessageHandler;
pub use plugins::{FileMessagePlugin, TextMessagePlugin};
pub use iroh::LocalFinder;
use std::sync::{Arc, Mutex};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = db::migrations::get_migrations();
// 创建消息路由器
let (iroh_sender, iroh_receiver) = tokio::sync::mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(FileMessagePlugin::new())
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(iroh_sender)
.build();
// 创建 Tauri 处理器和管理状态
let handler_state = Arc::new(Mutex::new(router));
// 创建局域网发现管理器
let local_finder_state: Arc<Mutex<Option<LocalFinder>>> = Arc::new(Mutex::new(None));
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:app.db", migrations)
.build(),
)
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![
handlers::tauri_handlers::send_message,
handlers::tauri_handlers::get_supported_message_types,
handlers::tauri_handlers::initialize_local_finder,
handlers::tauri_handlers::start_discovery,
handlers::tauri_handlers::stop_discovery,
handlers::tauri_handlers::get_local_node_id,
handlers::tauri_handlers::get_discovered_peers,
])
.manage(handler_state)
.manage(local_finder_state)
.setup(|app| {
// 启动 Iroh 接收处理任务
let _app_handle = app.handle();
let mut iroh_receiver = iroh_receiver;
tauri::async_runtime::spawn(async move {
while let Some(_binary_data) = iroh_receiver.recv().await {
println!("Received binary data from Iroh: {} bytes", _binary_data.len());
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+130
View File
@@ -0,0 +1,130 @@
use super::{BinaryData, MessagePlugin, PluginResult};
use serde_json::{json, Value};
/// 文件消息插件
pub struct FileMessagePlugin;
impl FileMessagePlugin {
pub fn new() -> Self {
FileMessagePlugin {}
}
}
impl Default for FileMessagePlugin {
fn default() -> Self {
Self::new()
}
}
impl MessagePlugin for FileMessagePlugin {
fn get_plugin_type(&self) -> &str {
"file"
}
fn before_send(&self, json_data: &Value) -> PluginResult<BinaryData> {
let to = json_data.get("to")
.and_then(|v| v.as_str())
.ok_or("Missing 'to' field")?;
let from = json_data.get("from")
.and_then(|v| v.as_str())
.ok_or("Missing 'from' field")?;
let processed_data = json!({
"type": "file",
"to": to,
"from": from,
"timestamp": chrono::Utc::now().timestamp(),
});
Ok(serde_json::to_vec(&processed_data)?)
}
fn before_receive(&self, binary_data: &BinaryData) -> PluginResult<Value> {
let json_value: Value = serde_json::from_slice(binary_data)?;
Ok(json_value)
}
fn after_send(&self, _json_data: &Value) -> PluginResult<()> {
Ok(())
}
fn after_receive(&self, _binary_data: &BinaryData) -> PluginResult<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_get_plugin_type() {
let plugin = FileMessagePlugin::new();
assert_eq!(plugin.get_plugin_type(), "file");
}
#[test]
fn test_before_send_valid_message() {
let plugin = FileMessagePlugin::new();
let message = json!({
"type": "file",
"to": "user123",
"from": "user456"
});
let result = plugin.before_send(&message);
assert!(result.is_ok());
let binary_data = result.unwrap();
let parsed: Value = serde_json::from_slice(&binary_data).unwrap();
assert_eq!(parsed["type"], "file");
assert_eq!(parsed["to"], "user123");
assert_eq!(parsed["from"], "user456");
assert!(parsed["timestamp"].is_number());
}
#[test]
fn test_before_send_missing_to_field() {
let plugin = FileMessagePlugin::new();
let message = json!({
"type": "file",
"from": "user456"
});
let result = plugin.before_send(&message);
assert!(result.is_err());
}
#[test]
fn test_before_send_missing_from_field() {
let plugin = FileMessagePlugin::new();
let message = json!({
"type": "file",
"to": "user123"
});
let result = plugin.before_send(&message);
assert!(result.is_err());
}
#[test]
fn test_before_receive() {
let plugin = FileMessagePlugin::new();
let original_message = json!({
"type": "file",
"to": "user123",
"from": "user456",
"timestamp": 1234567890
});
let binary_data = serde_json::to_vec(&original_message).unwrap();
let result = plugin.before_receive(&binary_data);
assert!(result.is_ok());
let received = result.unwrap();
assert_eq!(received["type"], "file");
}
}
+28
View File
@@ -0,0 +1,28 @@
use serde_json::Value;
use std::error::Error;
pub mod file_plugin;
pub mod text_plugin;
pub use file_plugin::FileMessagePlugin;
pub use text_plugin::TextMessagePlugin;
pub type PluginResult<T> = Result<T, Box<dyn Error>>;
pub type BinaryData = Vec<u8>;
pub trait MessagePlugin: Send + Sync {
/// 获取插件类型标识
fn get_plugin_type(&self) -> &str;
/// 发送消息前的处理
fn before_send(&self, json_data: &Value) -> PluginResult<BinaryData>;
/// 接收消息前的处理
fn before_receive(&self, binary_data: &BinaryData) -> PluginResult<Value>;
/// 发送消息后的处理
fn after_send(&self, json_data: &Value) -> PluginResult<()>;
/// 接收消息后的处理
fn after_receive(&self, binary_data: &BinaryData) -> PluginResult<()>;
}
+165
View File
@@ -0,0 +1,165 @@
use super::{BinaryData, MessagePlugin, PluginResult};
use serde_json::{json, Value};
/// 文本消息插件
pub struct TextMessagePlugin;
impl TextMessagePlugin {
pub fn new() -> Self {
TextMessagePlugin {}
}
}
impl Default for TextMessagePlugin {
fn default() -> Self {
Self::new()
}
}
impl MessagePlugin for TextMessagePlugin {
fn get_plugin_type(&self) -> &str {
"text"
}
fn before_send(&self, json_data: &Value) -> PluginResult<BinaryData> {
let to = json_data.get("to")
.and_then(|v| v.as_str())
.ok_or("Missing 'to' field")?;
let from = json_data.get("from")
.and_then(|v| v.as_str())
.ok_or("Missing 'from' field")?;
let content = json_data.get("content")
.and_then(|v| v.as_str())
.ok_or("Missing 'content' field")?;
let processed_data = json!({
"type": "text",
"to": to,
"from": from,
"content": content,
"timestamp": chrono::Utc::now().timestamp(),
});
Ok(serde_json::to_vec(&processed_data)?)
}
fn before_receive(&self, binary_data: &BinaryData) -> PluginResult<Value> {
let json_value: Value = serde_json::from_slice(binary_data)?;
Ok(json_value)
}
fn after_send(&self, _json_data: &Value) -> PluginResult<()> {
Ok(())
}
fn after_receive(&self, _binary_data: &BinaryData) -> PluginResult<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_get_plugin_type() {
let plugin = TextMessagePlugin::new();
assert_eq!(plugin.get_plugin_type(), "text");
}
#[test]
fn test_before_send_valid_message() {
let plugin = TextMessagePlugin::new();
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Hello, World!"
});
let result = plugin.before_send(&message);
assert!(result.is_ok());
let binary_data = result.unwrap();
let parsed: Value = serde_json::from_slice(&binary_data).unwrap();
assert_eq!(parsed["type"], "text");
assert_eq!(parsed["to"], "user123");
assert_eq!(parsed["from"], "user456");
assert_eq!(parsed["content"], "Hello, World!");
assert!(parsed["timestamp"].is_number());
}
#[test]
fn test_before_send_missing_to_field() {
let plugin = TextMessagePlugin::new();
let message = json!({
"type": "text",
"from": "user456",
"content": "Hello"
});
let result = plugin.before_send(&message);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Missing 'to' field"));
}
#[test]
fn test_before_send_missing_from_field() {
let plugin = TextMessagePlugin::new();
let message = json!({
"type": "text",
"to": "user123",
"content": "Hello"
});
let result = plugin.before_send(&message);
assert!(result.is_err());
}
#[test]
fn test_before_send_missing_content_field() {
let plugin = TextMessagePlugin::new();
let message = json!({
"type": "text",
"to": "user123",
"from": "user456"
});
let result = plugin.before_send(&message);
assert!(result.is_err());
}
#[test]
fn test_before_receive() {
let plugin = TextMessagePlugin::new();
let original_message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Test message",
"timestamp": 1234567890
});
let binary_data = serde_json::to_vec(&original_message).unwrap();
let result = plugin.before_receive(&binary_data);
assert!(result.is_ok());
let received = result.unwrap();
assert_eq!(received["type"], "text");
assert_eq!(received["content"], "Test message");
}
#[test]
fn test_after_send_and_receive() {
let plugin = TextMessagePlugin::new();
let message = json!({"test": "data"});
let binary_data = vec![1, 2, 3];
assert!(plugin.after_send(&message).is_ok());
assert!(plugin.after_receive(&binary_data).is_ok());
}
}
+254
View File
@@ -0,0 +1,254 @@
use crate::plugins::{BinaryData, MessagePlugin};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc;
type MessageCallback = Box<dyn Fn(Value) + Send + Sync + 'static>;
/// 消息路由器
pub struct MessageRouter {
plugins: Arc<RwLock<HashMap<String, Arc<dyn MessagePlugin>>>>,
message_callback: Arc<RwLock<Option<MessageCallback>>>,
iroh_sender: Option<mpsc::Sender<BinaryData>>,
}
pub struct MessageRouterBuilder {
router: MessageRouter,
}
impl MessageRouter {
pub fn builder() -> MessageRouterBuilder {
MessageRouterBuilder {
router: MessageRouter {
plugins: Arc::new(RwLock::new(HashMap::new())),
message_callback: Arc::new(RwLock::new(None)),
iroh_sender: None,
},
}
}
/// 发送消息
pub async fn send(&self, message: Value) -> Result<(), Box<dyn std::error::Error>> {
let message_type = message.get("type")
.and_then(|v| v.as_str())
.ok_or("Message must have a 'type' field")?;
// 先获取插件并释放锁
let plugin = {
let plugins = self.plugins.read().map_err(|_| "Failed to read plugins")?;
let plugin = plugins.get(message_type)
.ok_or_else(|| format!("No plugin found for message type: {}", message_type))?;
Arc::clone(plugin)
};
// 在锁释放后处理消息
let processed_data = plugin.before_send(&message)?;
if let Some(sender) = &self.iroh_sender {
sender.send(processed_data).await?;
} else {
return Err("Iroh sender not initialized".into());
}
plugin.after_send(&message)?;
Ok(())
}
/// 处理接收到的消息
pub fn handle_received_message(&self, binary_data: BinaryData) -> Result<(), Box<dyn std::error::Error>> {
let json_value: Value = serde_json::from_slice(&binary_data)?;
let message_type = json_value.get("type")
.and_then(|v| v.as_str())
.ok_or("Received message must have a 'type' field")?;
// 先获取插件并释放锁
let plugin = {
let plugins = self.plugins.read().map_err(|_| "Failed to read plugins")?;
let plugin = plugins.get(message_type)
.ok_or_else(|| format!("No plugin found for message type: {}", message_type))?;
Arc::clone(plugin)
};
let processed_message = plugin.before_receive(&binary_data)?;
if let Some(callback) = &*self.message_callback.read().map_err(|_| "Failed to read callback")? {
callback(processed_message);
}
plugin.after_receive(&binary_data)?;
Ok(())
}
}
impl MessageRouterBuilder {
/// 注册插件
pub fn register_plugin<P>(self, plugin: P) -> Self
where
P: MessagePlugin + 'static,
{
let plugin_type = plugin.get_plugin_type().to_string();
self.router.plugins.write()
.expect("Failed to write plugins")
.insert(plugin_type, Arc::new(plugin));
self
}
/// 设置消息回调
pub fn on_message<F>(self, callback: F) -> Self
where
F: Fn(Value) + Send + Sync + 'static,
{
*self.router.message_callback.write()
.expect("Failed to write callback") = Some(Box::new(callback));
self
}
/// 设置 Iroh 发送器
pub fn with_iroh_sender(mut self, sender: mpsc::Sender<BinaryData>) -> Self {
self.router.iroh_sender = Some(sender);
self
}
/// 构建路由器
pub fn build(self) -> MessageRouter {
self.router
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::TextMessagePlugin;
use serde_json::json;
use std::sync::{Arc, Mutex};
#[test]
fn test_router_builder() {
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.build();
let plugins = router.plugins.read().unwrap();
assert!(plugins.contains_key("text"));
}
#[test]
fn test_register_multiple_plugins() {
use crate::plugins::FileMessagePlugin;
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.register_plugin(FileMessagePlugin::new())
.build();
let plugins = router.plugins.read().unwrap();
assert!(plugins.contains_key("text"));
assert!(plugins.contains_key("file"));
assert_eq!(plugins.len(), 2);
}
#[tokio::test]
async fn test_send_text_message() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Test message"
});
let result = router.send(message).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_message_without_iroh_sender() {
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.build();
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Test"
});
let result = router.send(message).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Iroh sender not initialized"));
}
#[tokio::test]
async fn test_send_message_with_unknown_type() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
let message = json!({
"type": "unknown",
"to": "user123",
"from": "user456"
});
let result = router.send(message).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No plugin found"));
}
#[test]
fn test_handle_received_message() {
let received_messages = Arc::new(Mutex::new(Vec::new()));
let received_clone = Arc::clone(&received_messages);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.on_message(move |msg| {
received_clone.lock().unwrap().push(msg);
})
.build();
let original_message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Incoming message"
});
let binary_data = serde_json::to_vec(&original_message).unwrap();
let result = router.handle_received_message(binary_data);
assert!(result.is_ok());
let messages = received_messages.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["type"], "text");
assert_eq!(messages[0]["content"], "Incoming message");
}
#[test]
fn test_handle_received_message_invalid_type() {
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.build();
// 发送一个没有 type 字段的消息
let invalid_message = vec![1, 2, 3]; // 无效的 JSON
let result = router.handle_received_message(invalid_message);
assert!(result.is_err());
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod message_router;
pub use message_router::MessageRouter;
+3 -1
View File
@@ -14,7 +14,9 @@
{
"title": "rolegram",
"width": 800,
"height": 600
"height": 600,
"minWidth": 600,
"minHeight": 500
}
],
"security": {
Binary file not shown.
+213
View File
@@ -0,0 +1,213 @@
// 消息路由系统测试示例
// 这些代码展示了如何测试各个组件
#[cfg(test)]
mod integration_tests {
use serde_json::json;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
// 导入被测试的模块
use crate::plugins::{MessagePlugin, TextMessagePlugin, FileMessagePlugin};
use crate::router::MessageRouter;
use crate::handlers::TauriMessageHandler;
/// 集成测试:完整的消息发送流程
#[tokio::test]
async fn test_full_message_flow() {
// 1. 创建 channel
let (sender, mut receiver) = mpsc::channel(100);
// 2. 记录接收到的消息
let received_messages = Arc::new(Mutex::new(Vec::new()));
let received_clone = Arc::clone(&received_messages);
// 3. 创建路由器
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.on_message(move |msg| {
received_clone.lock().unwrap().push(msg);
})
.with_iroh_sender(sender)
.build();
// 4. 发送消息
let message = json!({
"type": "text",
"to": "user123",
"from": "user456",
"content": "Integration test message"
});
let result = router.send(message.clone()).await;
assert!(result.is_ok());
// 5. 验证消息被发送到 channel
let sent_data = receiver.recv().await.unwrap();
let sent_json: serde_json::Value = serde_json::from_slice(&sent_data).unwrap();
assert_eq!(sent_json["type"], "text");
assert_eq!(sent_json["content"], "Integration test message");
// 6. 模拟接收消息
let binary_data = serde_json::to_vec(&sent_json).unwrap();
let result = router.handle_received_message(binary_data);
assert!(result.is_ok());
// 7. 验证回调被调用
let messages = received_messages.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["content"], "Integration test message");
}
/// 测试多插件协作
#[tokio::test]
async fn test_multiple_plugins() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.register_plugin(FileMessagePlugin::new())
.with_iroh_sender(sender)
.build();
// 发送文本消息
let text_msg = json!({
"type": "text",
"to": "user1",
"from": "user2",
"content": "Text"
});
assert!(router.send(text_msg).await.is_ok());
// 发送文件消息
let file_msg = json!({
"type": "file",
"to": "user1",
"from": "user2"
});
assert!(router.send(file_msg).await.is_ok());
}
/// 测试错误处理
#[tokio::test]
async fn test_error_handling() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
// 测试缺失必要字段
let invalid_msg = json!({
"type": "text",
"content": "Missing to and from"
});
let result = router.send(invalid_msg).await;
assert!(result.is_err());
// 测试未知消息类型
let unknown_type = json!({
"type": "unknown_type",
"to": "user1",
"from": "user2"
});
let result = router.send(unknown_type).await;
assert!(result.is_err());
}
/// 测试 Handler 层
#[tokio::test]
async fn test_handler_layer() {
let (sender, _receiver) = mpsc::channel(100);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
let handler = TauriMessageHandler::new(router);
// 测试成功的命令调用
let message = json!({
"type": "text",
"to": "user1",
"from": "user2",
"content": "Handler test"
});
let result = handler.send_message(message).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Message sent successfully");
// 测试获取支持的消息类型
let types = handler.get_supported_message_types().unwrap();
assert!(types.contains(&"text".to_string()));
}
/// 性能测试:大量消息发送
#[tokio::test]
async fn test_performance_many_messages() {
let (sender, mut receiver) = mpsc::channel(1000);
let router = MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build();
// 发送 100 条消息
for i in 0..100 {
let message = json!({
"type": "text",
"to": "user1",
"from": "user2",
"content": format!("Message {}", i)
});
let result = router.send(message).await;
assert!(result.is_ok());
}
// 验证所有消息都被发送
let mut count = 0;
while let Ok(_) = receiver.try_recv() {
count += 1;
}
assert_eq!(count, 100);
}
/// 测试并发安全性
#[tokio::test]
async fn test_concurrent_safety() {
let (sender, _receiver) = mpsc::channel(100);
let router = Arc::new(
MessageRouter::builder()
.register_plugin(TextMessagePlugin::new())
.with_iroh_sender(sender)
.build()
);
// 创建多个并发任务
let mut handles = vec![];
for i in 0..10 {
let router_clone = Arc::clone(&router);
let handle = tokio::spawn(async move {
let message = json!({
"type": "text",
"to": "user1",
"from": "user2",
"content": format!("Concurrent message {}", i)
});
router_clone.send(message).await
});
handles.push(handle);
}
// 等待所有任务完成
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}
}
+15
View File
@@ -1,5 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { RouterView } from 'vue-router';
// tauri
import { invoke } from '@tauri-apps/api/core';
onMounted(()=>{
console.log('App mounted!');
invoke('get_supported_message_types').then((appName) => {
console.log(appName);
});
})
</script>
<template>
+95
View File
@@ -0,0 +1,95 @@
import type { Workflow } from '@/composables/useWorkflow'
import { getDatabase, initWorkflowTable } from '@/utils/database.js'
// 获取所有工作流
export async function getWorkflows(): Promise<Workflow[]> {
try {
const db = await getDatabase()
const results: any[] = await db.select('SELECT * FROM workflows ORDER BY createTime DESC')
return results as Workflow[]
} catch (error) {
console.error('获取工作流失败:', error)
return []
}
}
// 根据 ID 获取工作流
export async function getWorkflowById(id: number): Promise<Workflow | null> {
try {
const db = await getDatabase()
const results: any[] = await db.select('SELECT * FROM workflows WHERE id = ?', [id])
if (results.length > 0) {
return results[0] as Workflow
}
return null
} catch (error) {
console.error('获取工作流详情失败:', error)
return null
}
}
// 创建工作流
export async function createWorkflow(workflow: Omit<Workflow, 'id'>): Promise<number | null> {
try {
const db = await getDatabase()
await initWorkflowTable()
const result = await db.execute(
'INSERT INTO workflows (name, status, creator, createTime, definition) VALUES (?, ?, ?, ?, ?)',
[workflow.name, workflow.status, workflow.creator, workflow.createTime, workflow.definition || null]
)
return result.lastInsertId as number
} catch (error) {
console.error('创建工作流失败:', error)
return null
}
}
// 更新工作流
export async function updateWorkflow(id: number, workflow: Partial<Workflow>): Promise<boolean> {
try {
const db = await getDatabase()
const fields: string[] = []
const values: any[] = []
if (workflow.name !== undefined) {
fields.push('name = ?')
values.push(workflow.name)
}
if (workflow.status !== undefined) {
fields.push('status = ?')
values.push(workflow.status)
}
if (workflow.definition !== undefined) {
fields.push('definition = ?')
values.push(workflow.definition)
}
if (fields.length === 0) {
return false
}
fields.push('updatedTime = ?')
values.push(new Date().toISOString())
values.push(id)
await db.execute(
`UPDATE workflows SET ${fields.join(', ')} WHERE id = ?`,
values
)
return true
} catch (error) {
console.error('更新工作流失败:', error)
return false
}
}
// 删除工作流
export async function deleteWorkflow(id: number): Promise<boolean> {
try {
const db = await getDatabase()
await db.execute('DELETE FROM workflows WHERE id = ?', [id])
return true
} catch (error) {
console.error('删除工作流失败:', error)
return false
}
}
+5 -1
View File
@@ -3,9 +3,13 @@
@utility input {
@apply w-full outline-none ;
@apply w-full outline-none;
}
.lucide {
@apply my-1.5 size-4;
}
#app {
@apply h-screen w-screen;
}
@@ -0,0 +1,66 @@
<template>
<div class="space-y-4">
<!-- 任务列表 -->
<div class="space-y-2">
<div
v-for="task in tasks"
:key="task.id"
@click="$emit('select', task)"
:class="[
'p-3 rounded-lg cursor-pointer transition-colors border-l-4',
selectedTaskId === task.id
? 'bg-primary text-primary-content border-primary'
: 'bg-base-100 hover:bg-base-300 border-transparent'
]"
>
<div class="flex justify-between items-start">
<div class="font-semibold text-sm">{{ task.name }}</div>
<span :class="getStatusBadgeClass(task.status)">{{ task.status }}</span>
</div>
<div class="text-xs opacity-70 mt-1">
{{ getTriggerTypeLabel(task.triggerType) }}
</div>
</div>
<div v-if="tasks.length === 0" class="text-center opacity-50 py-8">
<p>暂无自动化任务</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AutomationTask } from '@/composables/useAutomation'
const props = defineProps<{
tasks: AutomationTask[]
selectedTaskId: number | null
}>()
const emit = defineEmits<{
select: [task: AutomationTask]
}>()
const getStatusBadgeClass = (status: string) => {
const baseClass = 'badge badge-sm'
switch (status) {
case '运行中':
return `${baseClass} badge-info`
case '已暂停':
return `${baseClass} badge-warning`
case '草稿':
return `${baseClass} badge-ghost`
default:
return baseClass
}
}
const getTriggerTypeLabel = (type: string) => {
const labels: Record<string, string> = {
cron: '定时触发',
event: '事件触发',
manual: '手动触发',
}
return labels[type] || type
}
</script>
@@ -0,0 +1,118 @@
<template>
<div class="card bg-base-100 p-4">
<h3 class="font-bold mb-3">自动化规则</h3>
<div class="space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text">规则名称</span>
</label>
<input
:value="modelValue?.name"
@input="updateName($event)"
type="text"
placeholder="请输入规则名称"
class="input input-bordered input-sm"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">触发条件</span>
</label>
<select
:value="modelValue?.trigger"
@input="updateTrigger($event)"
class="select select-bordered select-sm"
>
<option value="">选择触发条件</option>
<option v-for="trigger in triggers" :key="trigger" :value="trigger">
{{ trigger }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">执行动作</span>
</label>
<select
:value="modelValue?.action"
@input="updateAction($event)"
class="select select-bordered select-sm"
>
<option value="">选择执行动作</option>
<option v-for="action in actions" :key="action" :value="action">
{{ action }}
</option>
</select>
</div>
<div class="flex items-center gap-2">
<input
:checked="modelValue?.enabled"
@change="updateEnabled(($event.target as HTMLInputElement).checked)"
type="checkbox"
class="checkbox checkbox-sm"
/>
<span class="text-sm">启用此规则</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface AutomationRule {
name?: string
trigger?: string
action?: string
enabled?: boolean
}
const props = defineProps<{
modelValue?: AutomationRule
}>()
const emit = defineEmits<{
'update:modelValue': [value: AutomationRule]
}>()
const triggers = [
'流程启动时',
'流程完成时',
'流程失败时',
'节点执行前',
'节点执行后',
'数据变更时',
'定时触发',
]
const actions = [
'发送邮件',
'发送消息',
'执行脚本',
'调用 API',
'更新数据',
'创建任务',
'记录日志',
]
const updateName = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', { ...props.modelValue, name: target.value })
}
const updateTrigger = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, trigger: target.value })
}
const updateAction = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, action: target.value })
}
const updateEnabled = (value: boolean) => {
emit('update:modelValue', { ...props.modelValue, enabled: value })
}
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Cron 表达式</span>
</label>
<input
:value="modelValue"
@input="updateValue($event)"
type="text"
placeholder="0 0 12 * * ?"
class="input input-bordered font-mono"
/>
</div>
<!-- 快捷设置 -->
<div class="space-y-2">
<label class="label">
<span class="label-text">快捷设置</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="(expr, label) in presetExpressions"
:key="label"
@click="selectPreset(expr)"
class="btn btn-xs btn-outline"
>
{{ label }}
</button>
</div>
</div>
<!-- 表达式说明 -->
<div class="text-xs opacity-70 bg-base-200 p-3 rounded">
<div class="font-semibold mb-2">Cron 表达式格式</div>
<div class="grid grid-cols-6 gap-2 text-center">
<div></div>
<div>分钟</div>
<div>小时</div>
<div>日期</div>
<div>月份</div>
<div>星期</div>
</div>
<div class="mt-2 text-center font-mono">{{ modelValue || '0 0 12 * * ?' }}</div>
</div>
<!-- 下次执行时间预览 -->
<div v-if="nextRunTime" class="text-sm">
<span class="opacity-70">下次执行时间</span>
<span class="font-semibold">{{ nextRunTime }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
modelValue?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const presetExpressions: Record<string, string> = {
'每分钟': '0 * * * * ?',
'每小时': '0 0 * * * ?',
'每天零点': '0 0 0 * * ?',
'每天中午': '0 0 12 * * ?',
'每周一': '0 0 9 ? * MON',
'每月 1 号': '0 0 0 1 * ?',
}
const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const selectPreset = (expr: string) => {
emit('update:modelValue', expr)
}
// 计算下次执行时间(简化版本)
const nextRunTime = computed(() => {
if (!props.modelValue) return ''
// TODO: 实现真实的 Cron 解析
return '2026-03-12 00:00:00'
})
</script>
@@ -0,0 +1,90 @@
<template>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">触发事件</span>
</label>
<select :value="modelValue?.event" @input="updateEvent($event)" class="select select-bordered">
<option value="">选择事件</option>
<option v-for="event in availableEvents" :key="event" :value="event">
{{ event }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">触发条件</span>
</label>
<select :value="modelValue?.condition" @input="updateCondition($event)" class="select select-bordered">
<option value="equals">等于</option>
<option value="contains">包含</option>
<option value="greaterThan">大于</option>
<option value="lessThan">小于</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">条件值</span>
</label>
<input
:value="modelValue?.value"
@input="updateValue($event)"
type="text"
placeholder="请输入条件值"
class="input input-bordered"
/>
</div>
<div class="text-xs opacity-70 bg-base-200 p-3 rounded">
<div class="font-semibold mb-2">可用事件</div>
<ul class="list-disc list-inside space-y-1">
<li>流程完成 - 当某个流程执行完成时触发</li>
<li>数据更新 - 当数据库记录更新时触发</li>
<li>文件上传 - 当有新文件上传时触发</li>
<li>API 调用 - 当收到特定 API 请求时触发</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
interface EventConfig {
event?: string
condition?: string
value?: string
}
const props = defineProps<{
modelValue?: EventConfig
}>()
const emit = defineEmits<{
'update:modelValue': [value: EventConfig]
}>()
const availableEvents = [
'流程完成',
'数据更新',
'文件上传',
'API 调用',
'系统启动',
'用户登录',
]
const updateEvent = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, event: target.value })
}
const updateCondition = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, condition: target.value })
}
const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', { ...props.modelValue, value: target.value })
}
</script>
@@ -0,0 +1,129 @@
<template>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">循环类型</span>
</label>
<select :value="modelValue?.loopType" @input="updateLoopType($event)" class="select select-bordered">
<option value="once">仅执行一次</option>
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
<option value="custom">自定义</option>
</select>
</div>
<div v-if="modelValue?.loopType === 'custom'" class="space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text">执行间隔</span>
</label>
<div class="flex gap-2">
<input
:value="modelValue?.interval"
@input="updateInterval($event)"
type="number"
min="1"
class="input input-bordered w-24"
/>
<select
:value="modelValue?.intervalUnit"
@input="updateIntervalUnit($event)"
class="select select-bordered flex-1"
>
<option value="minutes">分钟</option>
<option value="hours">小时</option>
<option value="days"></option>
<option value="weeks"></option>
</select>
</div>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">超时设置</span>
</label>
<input
:value="modelValue?.timeout"
@input="updateTimeout($event)"
type="number"
min="0"
class="input input-bordered"
placeholder="0 表示不限制"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">失败重试次数</span>
</label>
<input
:value="modelValue?.retryCount"
@input="updateRetryCount($event)"
type="number"
min="0"
max="10"
class="input input-bordered"
/>
</div>
<div class="flex items-center gap-2">
<input
:checked="modelValue?.enabled"
@change="updateEnabled(($event.target as HTMLInputElement).checked)"
type="checkbox"
class="checkbox checkbox-sm"
/>
<span class="text-sm">启用此调度</span>
</div>
</div>
</template>
<script setup lang="ts">
interface Schedule {
loopType?: string
interval?: number
intervalUnit?: string
timeout?: number
retryCount?: number
enabled?: boolean
}
const props = defineProps<{
modelValue?: Schedule
}>()
const emit = defineEmits<{
'update:modelValue': [value: Schedule]
}>()
const updateLoopType = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, loopType: target.value })
}
const updateInterval = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', { ...props.modelValue, interval: Number(target.value) })
}
const updateIntervalUnit = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', { ...props.modelValue, intervalUnit: target.value })
}
const updateTimeout = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', { ...props.modelValue, timeout: Number(target.value) })
}
const updateRetryCount = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', { ...props.modelValue, retryCount: Number(target.value) })
}
const updateEnabled = (value: boolean) => {
emit('update:modelValue', { ...props.modelValue, enabled: value })
}
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="chat" :class="isSelf ? 'chat-end' : 'chat-start'">
<!-- 头像 -->
<div v-if="avatar" class="chat-image avatar">
<div class="w-10 rounded-full">
<img :alt="avatar" :src="avatar" />
</div>
</div>
<!-- 消息气泡 -->
<div class="chat-header mb-1">
<time v-if="time" class="text-xs opacity-50">{{ time }}</time>
</div>
<div
class="chat-bubble"
:class="isSelf ? 'chat-bubble-primary' : 'chat-bubble-neutral'"
>
{{ content }}
</div>
<div v-if="showFooter" class="chat-footer mt-1">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
//
content: string
//
isSelf?: boolean
//
time?: string
// URL
avatar?: string
//
showFooter?: boolean
}>()
</script>
<style scoped>
.chat {
margin-bottom: 0.75rem;
}
.chat-bubble {
max-width: 70%;
word-wrap: break-word;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="w-full border-t border-base-300 p-4 bg-base-100">
<div class="flex gap-2">
<!-- 输入框 -->
<textarea
ref="inputRef"
v-model="message"
class="textarea textarea-bordered flex-1"
placeholder="输入消息..."
rows="1"
@keydown.enter.exact.prevent="handleSend"
@input="autoResize"
></textarea>
<!-- 发送按钮 -->
<button
class="btn btn-primary"
:disabled="!message.trim()"
@click="handleSend"
>
发送
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
send: [content: string]
}>()
const message = ref('')
const inputRef = ref<HTMLTextAreaElement | null>(null)
//
const autoResize = () => {
const el = inputRef.value
if (el) {
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
}
//
const handleSend = () => {
const content = message.value.trim()
if (!content) return
emit('send', content)
message.value = ''
//
const el = inputRef.value
if (el) {
el.style.height = 'auto'
}
}
</script>
<style scoped>
textarea {
resize: none;
max-height: 120px;
overflow-y: auto;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="flex flex-col w-full h-full bg-base-200 overflow-hidden">
<!-- 消息气泡列表 -->
<div ref="bubbleListRef" class="flex-1 overflow-y-auto p-4 ">
<!-- 空状态 -->
<div v-if="bubbles.length === 0" class="flex items-center justify-center h-full">
<p class="text-base-content opacity-50">暂无消息发送第一条消息开始对话吧</p>
</div>
<!-- 消息列表 -->
<template v-else>
<ChatBubble v-for="bubble in bubbles" :key="bubble.id" :content="bubble.content" :is-self="bubble.isSelf"
:time="bubble.time" :avatar="getAvatar(bubble.isSelf)" />
</template>
</div>
<!-- 输入框区域 -->
<ChatInput @send="handleSend" />
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ChatBubble from './ChatBubble.vue'
import ChatInput from './ChatInput.vue'
import type { ChatBubble as ChatBubbleType } from '@/composables/useChat'
const props = defineProps<{
//
bubbles: ChatBubbleType[]
}>()
const emit = defineEmits<{
send: [content: string]
}>()
const bubbleListRef = ref<HTMLDivElement | null>(null)
//
const getAvatar = (isSelf: boolean): string => {
return isSelf
? 'https://api.dicebear.com/7.x/avataaars/svg?seed=User'
: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Assistant'
}
//
const scrollToBottom = () => {
const el = bubbleListRef.value
if (el) {
// 使
el.scrollTo({
top: el.scrollHeight,
behavior: 'smooth'
})
}
}
//
watch(
() => props.bubbles.length,
() => {
// DOM
setTimeout(() => {
scrollToBottom()
}, 0)
},
{ immediate: true }
)
//
const handleSend = (content: string) => {
emit('send', content)
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: #f1f1f1;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
-38
View File
@@ -1,38 +0,0 @@
<template>
<div class="space-y-4">
<div>
<h3 class="font-bold text-lg">{{ item.title }}</h3>
<p class="text-sm text-base-content/70 mt-1">{{ item.time }}</p>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<p class="text-sm">{{ item.content }}</p>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="emit('action', 'reply', item.id)" class="btn btn-sm btn-primary">
回复
</button>
<button @click="emit('action', 'markRead', item.id)" class="btn btn-sm btn-outline">
标记已读
</button>
<button @click="emit('action', 'delete', item.id)" class="btn btn-sm btn-ghost text-error">
删除
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useHome'
defineProps<{
item: Message
}>()
const emit = defineEmits<{
action: [action: string, id: number]
}>()
</script>
-36
View File
@@ -1,36 +0,0 @@
<template>
<ul class="list bg-base-100 rounded-box shadow-md">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide flex items-center justify-between">
<span class="mr-auto">本周任务</span>
<button class="cursor-pointer" title="新增">
<SquarePlus />
</button>
</li>
<li class="list-row" v-for="item in items" :class="{
'bg-primary': selectedId == item.id
}" @click="emit('select', item.id)">
<div class="list-col-grow">
<div>{{ item.title }}</div>
<div class="text-xs uppercase font-semibold opacity-60">{{ item.content }}</div>
</div>
<button class="btn btn-square btn-ghost">
<Trash></Trash>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useHome'
import { Trash, SquarePlus } from 'lucide-vue-next';
defineProps<{
items: Message[]
selectedId: number | null
}>()
const emit = defineEmits<{
select: [id: number]
}>()
</script>
-58
View File
@@ -1,58 +0,0 @@
<template>
<div class="space-y-4">
<div>
<h3 class="font-bold text-lg">{{ item.name }}</h3>
<div class="flex gap-2 mt-2">
<span class="badge badge-sm" :class="{
'badge-info': item.status === '进行中',
'badge-ghost': item.status === '规划中',
'badge-success': item.status === '已完成',
'badge-warning': item.status === '已暂停'
}">
{{ item.status }}
</span>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4 space-y-3">
<div class="flex justify-between">
<span class="text-sm text-base-content/70">项目负责人</span>
<span class="text-sm font-medium">{{ item.manager }}</span>
</div>
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-base-content/70">当前进度</span>
<span class="font-medium">{{ item.progress }}%</span>
</div>
<progress class="progress progress-primary w-full" :value="item.progress" max="100"></progress>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="emit('action', 'view', item.id)" class="btn btn-sm btn-outline">
查看详情
</button>
<button @click="emit('action', 'edit', item.id)" class="btn btn-sm btn-primary">
编辑项目
</button>
<button @click="emit('action', 'addMember', item.id)" class="btn btn-sm btn-secondary">
添加成员
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Project } from '@/composables/useHome'
defineProps<{
item: Project
}>()
const emit = defineEmits<{
action: [action: string, id: number]
}>()
</script>
-47
View File
@@ -1,47 +0,0 @@
<template>
<ul class="list bg-base-100 rounded-box shadow-md">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide flex items-center justify-between">
<span class="mr-auto">项目列表</span>
<button class="cursor-pointer" title="新增">
<SquarePlus />
</button>
</li>
<li class="list-row" v-for="item in items" :class="{
'bg-primary': selectedId == item.id
}" @click="emit('select', item.id)">
<div class="list-col-grow">
<div>{{ item.name }}</div>
<div class="text-xs uppercase font-semibold opacity-60">{{ item.manager }}</div>
</div>
<div class="flex flex-col gap-1">
<span class="badge badge-sm" :class="{
'badge-info': item.status === '进行中',
'badge-ghost': item.status === '规划中',
'badge-success': item.status === '已完成',
'badge-warning': item.status === '已暂停'
}">
{{ item.status }}
</span>
<progress class="progress progress-primary w-24" :value="item.progress" max="100"></progress>
</div>
<button class="btn btn-square btn-ghost">
<Trash></Trash>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import type { Project } from '@/composables/useHome'
import { Trash, SquarePlus } from 'lucide-vue-next';
defineProps<{
items: Project[]
selectedId: number | null
}>()
const emit = defineEmits<{
select: [id: number]
}>()
</script>
-54
View File
@@ -1,54 +0,0 @@
<template>
<div class="space-y-4">
<div>
<h3 class="font-bold text-lg">{{ item.name }}</h3>
<div class="flex gap-2 mt-2">
<span class="badge badge-sm" :class="{
'badge-info': item.status === '进行中',
'badge-success': item.status === '已完成',
'badge-ghost': item.status === '草稿',
'badge-warning': item.status === '已暂停'
}">
{{ item.status }}
</span>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4 space-y-2">
<div class="flex justify-between">
<span class="text-sm text-base-content/70">创建人</span>
<span class="text-sm font-medium">{{ item.creator }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-base-content/70">创建时间</span>
<span class="text-sm font-medium">{{ item.createTime }}</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="emit('action', 'edit', item.id)" class="btn btn-sm btn-primary">
编辑流程
</button>
<button @click="emit('action', 'view', item.id)" class="btn btn-sm btn-outline">
查看流程图
</button>
<button @click="emit('action', 'start', item.id)" class="btn btn-sm btn-success" :disabled="item.status !== '草稿'">
启动流程
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Workflow } from '@/composables/useHome'
defineProps<{
item: Workflow
}>()
const emit = defineEmits<{
action: [action: string, id: number]
}>()
</script>
-43
View File
@@ -1,43 +0,0 @@
<template>
<ul class="list bg-base-100 rounded-box shadow-md">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide flex items-center justify-between">
<span class="mr-auto">流程列表</span>
<button class="cursor-pointer" title="新增">
<SquarePlus />
</button>
</li>
<li class="list-row" v-for="item in items" :class="{
'bg-primary': selectedId == item.id
}" @click="emit('select', item.id)">
<div class="list-col-grow">
<div>{{ item.name }}</div>
<div class="text-xs uppercase font-semibold opacity-60">{{ item.creator }}</div>
</div>
<span class="badge badge-sm" :class="{
'badge-info': item.status === '进行中',
'badge-success': item.status === '已完成',
'badge-ghost': item.status === '草稿',
'badge-warning': item.status === '已暂停'
}">
{{ item.status }}
</span>
<button class="btn btn-square btn-ghost">
<Trash></Trash>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import type { Workflow } from '@/composables/useHome'
import { Trash, SquarePlus } from 'lucide-vue-next';
defineProps<{
items: Workflow[]
selectedId: number | null
}>()
const emit = defineEmits<{
select: [id: number]
}>()
</script>
-49
View File
@@ -1,49 +0,0 @@
<template>
<section class="card bg-base-100 shadow-sm h-full overflow-hidden">
<div class="card-body p-0 h-full">
<MessageList
v-if="currentFunction === 'message'"
:items="messages"
:selected-id="selectedMessageId"
@select="emit('selectMessage', $event)"
/>
<WorkflowList
v-else-if="currentFunction === 'workflow'"
:items="workflows"
:selected-id="selectedWorkflowId"
@select="emit('selectWorkflow', $event)"
/>
<ProjectList
v-else
:items="projects"
:selected-id="selectedProjectId"
@select="emit('selectProject', $event)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import type { FunctionType, Message, Workflow, Project } from '@/composables/useHome'
import MessageList from '../common/MessageList.vue'
import WorkflowList from '../common/WorkflowList.vue'
import ProjectList from '../common/ProjectList.vue'
defineProps<{
currentFunction: FunctionType
messages: Message[]
workflows: Workflow[]
projects: Project[]
selectedMessageId: number | null
selectedWorkflowId: number | null
selectedProjectId: number | null
}>()
const emit = defineEmits<{
selectMessage: [id: number]
selectWorkflow: [id: number]
selectProject: [id: number]
}>()
</script>
-32
View File
@@ -1,32 +0,0 @@
<template>
<ul class="menu w-full grow">
<!-- List item -->
<li v-for="item in buttons" class="mb-1">
<button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" :class="{
'bg-primary': currentFunction == item.type
}" :data-tip="item.label" @click="emit('select', item.type)">
<component :is="item.icon"></component>
<span class="is-drawer-close:hidden">{{ item.label }}</span>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import type { FunctionType } from '@/composables/useHome'
import { Mail, Workflow, FolderKanban } from 'lucide-vue-next'
defineProps<{
currentFunction: FunctionType
}>()
const emit = defineEmits<{
select: [func: FunctionType]
}>()
const buttons = [
{ type: 'message' as FunctionType, label: '消息列表', icon: Mail },
{ type: 'workflow' as FunctionType, label: '流程设计', icon: Workflow },
{ type: 'project' as FunctionType, label: '项目管理', icon: FolderKanban },
] as const
</script>
-59
View File
@@ -1,59 +0,0 @@
<template>
<section class="card bg-base-100 shadow-sm h-full overflow-hidden">
<div class="card-body p-4">
<h2 class="font-bold text-base mb-3">操作空间</h2>
<div class="overflow-y-auto h-[calc(100%-40px)]">
<!-- 空状态 -->
<div
v-if="!selectedMessage && !selectedWorkflow && !selectedProject"
class="flex flex-col items-center justify-center h-full text-base-content/50"
>
<div class="text-4xl mb-2">👈</div>
<p class="text-sm">请从列表中选择一个项目查看详情</p>
</div>
<!-- 消息详情 -->
<MessageDetail
v-if="currentFunction === 'message' && selectedMessage"
:item="selectedMessage"
@action="(action, id) => emit('messageAction', action, id)"
/>
<!-- 流程详情 -->
<WorkflowDetail
v-else-if="currentFunction === 'workflow' && selectedWorkflow"
:item="selectedWorkflow"
@action="(action, id) => emit('workflowAction', action, id)"
/>
<!-- 项目详情 -->
<ProjectDetail
v-else-if="currentFunction === 'project' && selectedProject"
:item="selectedProject"
@action="(action, id) => emit('projectAction', action, id)"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { FunctionType, Message, Workflow, Project } from '@/composables/useHome'
import MessageDetail from '../common/MessageDetail.vue'
import WorkflowDetail from '../common/WorkflowDetail.vue'
import ProjectDetail from '../common/ProjectDetail.vue'
defineProps<{
currentFunction: FunctionType
selectedMessage: Message | undefined
selectedWorkflow: Workflow | undefined
selectedProject: Project | undefined
}>()
const emit = defineEmits<{
messageAction: [action: string, id: number]
workflowAction: [action: string, id: number]
projectAction: [action: string, id: number]
}>()
</script>
+45
View File
@@ -0,0 +1,45 @@
<template>
<ul class="menu w-full grow">
<!-- List item -->
<li v-for="item in buttons" class="mb-1">
<RouterLink class="is-drawer-close:tooltip is-drawer-close:tooltip-right" :class="{
'bg-primary': currentFunction == item.type
}" :data-tip="item.label" :to="item.path" active-class="bg-primary">
<component :is="item.icon"></component>
<span class="is-drawer-close:hidden">{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</template>
<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import { MessageCircle, Workflow, Clock } from '@lucide/vue'
import { computed } from 'vue'
export type FunctionType = 'chat' | 'workflow' | 'automation'
const route = useRoute()
//
const currentFunction = computed<FunctionType>(() => {
const path = route.path
if (path.includes('/chat')) return 'chat'
if (path.includes('/workflow')) return 'workflow'
if (path.includes('/automation')) return 'automation'
return 'chat' //
})
interface NavItem {
type: FunctionType
label: string
icon: any
path: string
}
const buttons: NavItem[] = [
{ type: 'chat', label: '消息', icon: MessageCircle, path: '/chat' },
{ type: 'workflow', label: '流程', icon: Workflow, path: '/workflow' },
{ type: 'automation', label: '自动化', icon: Clock, path: '/automation' },
]
</script>
-72
View File
@@ -1,72 +0,0 @@
<template>
<div class="drawer lg:drawer-open">
<input id="my-drawer-4" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar -->
<nav class="navbar w-full bg-base-300">
<label for="my-drawer-4" aria-label="打开侧边栏" class="btn btn-square btn-ghost">
<!-- Sidebar toggle icon -->
<PanelLeftOpen class="[.drawer-toggle:checked~.drawer-content_.navbar_&]:hidden" />
<PanelLeftClose class="[.drawer-toggle:checked~.drawer-content_.navbar_&]:block hidden" />
</label>
<div class="px-4">{{ currentFunction }}</div>
</nav>
<!-- Page content here -->
<div class="p-4 flex-1 flex flex-row gap-1">
<div class="col-span-5 min-w-[300px]">
<FeatureList :current-function="currentFunction" :messages="messages" :workflows="workflows"
:projects="projects" :selected-message-id="selectedMessageId" :selected-workflow-id="selectedWorkflowId"
:selected-project-id="selectedProjectId" @select-message="selectMessage" @select-workflow="selectWorkflow"
@select-project="selectProject" />
</div>
<!-- 右侧操作空间 -->
<div class="col-span-5 flex-1">
<OperationSpace :current-function="currentFunction" :selected-message="selectedMessage"
:selected-workflow="selectedWorkflow" :selected-project="selectedProject"
@message-action="handleMessageAction" @workflow-action="handleWorkflowAction"
@project-action="handleProjectAction" />
</div>
</div>
</div>
<div class="drawer-side is-drawer-close:overflow-visible">
<label for="my-drawer-4" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex min-h-full flex-col items-start bg-base-200 is-drawer-close:w-14 is-drawer-open:w-64">
<!-- Sidebar content here -->
<FunctionNav :current-function="currentFunction" @select="selectFunction" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useHome } from '@/composables/useHome'
import FunctionNav from './FunctionNav.vue'
import FeatureList from './FeatureList.vue'
import OperationSpace from './OperationSpace.vue'
import { PanelLeftClose, PanelLeftOpen } from 'lucide-vue-next'
const {
currentFunction,
messages,
workflows,
projects,
selectedMessageId,
selectedWorkflowId,
selectedProjectId,
selectedMessage,
selectedWorkflow,
selectedProject,
selectFunction,
selectMessage,
selectWorkflow,
selectProject,
handleMessageAction,
handleWorkflowAction,
handleProjectAction,
} = useHome()
</script>
+3
View File
@@ -0,0 +1,3 @@
export * from './useChat'
export * from './useWorkflow'
export * from './useAutomation'
+134
View File
@@ -0,0 +1,134 @@
import { ref, computed } from 'vue'
export interface AutomationTask {
id: number
name: string
workflowId: number
status: '运行中' | '已暂停' | '草稿'
triggerType: 'cron' | 'event' | 'manual'
cronExpression?: string
eventConfig?: any
schedule?: any
nextRun?: string
createdAt?: string
updatedAt?: string
}
export function useAutomation() {
const tasks = ref<AutomationTask[]>([])
const selectedTaskId = ref<number | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// 计算属性:获取当前选中的任务
const selectedTask = computed(() =>
tasks.value.find(t => t.id === selectedTaskId.value)
)
// 加载任务列表
const loadTasks = async () => {
isLoading.value = true
error.value = null
try {
// TODO: 调用 API 获取任务列表
// const response = await fetch('/api/automation/tasks')
// tasks.value = await response.json()
// 模拟数据
tasks.value = [
{
id: 1,
name: '每日数据同步',
workflowId: 1,
status: '运行中',
triggerType: 'cron',
cronExpression: '0 0 2 * * ?',
nextRun: '2026-03-12 02:00:00',
createdAt: '2026-03-01 10:00:00',
},
{
id: 2,
name: '周报生成',
workflowId: 2,
status: '已暂停',
triggerType: 'cron',
cronExpression: '0 0 9 ? * MON',
nextRun: '未安排',
createdAt: '2026-03-05 14:30:00',
},
]
} catch (e) {
error.value = '加载任务列表失败'
console.error(e)
} finally {
isLoading.value = false
}
}
// 创建任务
const createTask = async (task: Omit<AutomationTask, 'id' | 'createdAt' | 'updatedAt'>) => {
const newTask: AutomationTask = {
...task,
id: Date.now(),
createdAt: new Date().toLocaleString(),
updatedAt: new Date().toLocaleString(),
}
tasks.value.push(newTask)
// TODO: 调用 API 创建任务
return newTask
}
// 更新任务
const updateTask = async (id: number, updates: Partial<AutomationTask>) => {
const index = tasks.value.findIndex(t => t.id === id)
if (index === -1) return
const updated = { ...tasks.value[index], ...updates, updatedAt: new Date().toLocaleString() }
tasks.value[index] = updated
// TODO: 调用 API 更新任务
}
// 删除任务
const deleteTask = async (id: number) => {
tasks.value = tasks.value.filter(t => t.id !== id)
if (selectedTaskId.value === id) {
selectedTaskId.value = null
}
// TODO: 调用 API 删除任务
}
// 启动任务
const startTask = async (id: number) => {
await updateTask(id, { status: '运行中' })
}
// 暂停任务
const pauseTask = async (id: number) => {
await updateTask(id, { status: '已暂停' })
}
// 选择任务
const selectTask = (id: number) => {
selectedTaskId.value = id
}
return {
// 状态
tasks,
selectedTaskId,
isLoading,
error,
// 计算属性
selectedTask,
// 方法
loadTasks,
createTask,
updateTask,
deleteTask,
startTask,
pauseTask,
selectTask,
}
}
+109
View File
@@ -0,0 +1,109 @@
import { ref, computed } from 'vue'
export interface ChatMessage {
id: number
title: string
content: string
time: string
pending?: boolean
avatar?: string
}
export interface ChatBubble {
id: number
content: string
time: string
isSelf: boolean
}
export type FilterType = 'all' | 'pending' | 'processed'
export function useChat() {
const selectedChatId = ref<number | null>(null)
// 模拟对话列表数据
const chats = ref<ChatMessage[]>([
{ id: 1, title: '系统助手', content: '欢迎使用角色管理系统,祝您工作顺利!', time: '10:00', pending: false, avatar: '系统' },
{ id: 2, title: '任务提醒', content: '您有一个待处理的任务需要在今天完成。', time: '11:30', pending: true, avatar: '任务' },
{ id: 3, title: '审批助手', content: '新的流程审批等待您的处理,请及时查看。', time: '14:20', pending: true, avatar: '审批' },
{ id: 4, title: '会议助手', content: '明天上午 10 点召开项目进度会议,请准时参加。', time: '15:00', pending: false, avatar: '会议' },
])
// 模拟聊天泡泡数据
const chatBubbles = ref<Record<number, ChatBubble[]>>({
1: [
{ id: 1, content: '欢迎使用角色管理系统,祝您工作顺利!', time: '10:00', isSelf: false },
{ id: 2, content: '谢谢,我该如何开始?', time: '10:01', isSelf: true },
{ id: 3, content: '您可以从左侧菜单开始探索各项功能', time: '10:02', isSelf: false },
],
2: [
{ id: 1, content: '您有一个待处理的任务需要在今天完成。', time: '11:30', isSelf: false },
],
3: [
{ id: 1, content: '新的流程审批等待您的处理,请及时查看。', time: '14:20', isSelf: false },
{ id: 2, content: '好的,我马上处理', time: '14:25', isSelf: true },
],
4: [
{ id: 1, content: '明天上午 10 点召开项目进度会议,请准时参加。', time: '15:00', isSelf: false },
],
})
// 计算属性:获取当前选中的对话
const selectedChat = computed(() =>
chats.value.find(c => c.id === selectedChatId.value)
)
// 计算属性:获取当前对话的泡泡列表
const currentBubbles = computed(() =>
selectedChatId.value ? (chatBubbles.value[selectedChatId.value] || []) : []
)
// 选择对话
const selectChat = (id: number) => {
selectedChatId.value = id
// 标记为已处理
const chat = chats.value.find(c => c.id === id)
if (chat) chat.pending = false
}
// 发送消息
const sendMessage = (content: string) => {
if (!selectedChatId.value || !content.trim()) return
const newBubble: ChatBubble = {
id: Date.now(),
content: content.trim(),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isSelf: true,
}
if (!chatBubbles.value[selectedChatId.value]) {
chatBubbles.value[selectedChatId.value] = []
}
chatBubbles.value[selectedChatId.value].push(newBubble)
}
// 操作处理函数
const handleChatAction = (action: string, chatId: number) => {
console.log(`处理对话 ${chatId}: ${action}`)
// TODO: 实现具体业务逻辑
}
return {
// 状态
selectedChatId,
// 数据
chats,
chatBubbles,
// 计算属性
selectedChat,
currentBubbles,
// 方法
selectChat,
sendMessage,
handleChatAction,
}
}
-137
View File
@@ -1,137 +0,0 @@
import { ref, computed } from 'vue'
export type FunctionType = 'message' | 'workflow' | 'project'
export interface Message {
id: number
title: string
content: string
time: string
unread?: boolean
}
export interface Workflow {
id: number
name: string
status: '进行中' | '已完成' | '草稿' | '已暂停'
creator: string
createTime: string
}
export interface Project {
id: number
name: string
status: '进行中' | '规划中' | '已完成' | '已暂停'
progress: number
manager: string
}
export function useHome() {
const currentFunction = ref<FunctionType>('message')
const selectedMessageId = ref<number | null>(null)
const selectedWorkflowId = ref<number | null>(null)
const selectedProjectId = ref<number | null>(null)
// 模拟数据
const messages= ref<Message[]>([
{ id: 1, title: '系统通知', content: '欢迎使用角色管理系统,祝您工作顺利!', time: '2026-03-10 10:00', unread: true },
{ id: 2, title: '任务提醒', content: '您有一个待处理的任务需要在今天完成。', time: '2026-03-10 11:30', unread: true },
{ id: 3, title: '审批请求', content: '新的流程审批等待您的处理,请及时查看。', time: '2026-03-10 14:20', unread: false },
{ id: 4, title: '会议通知', content: '明天上午 10 点召开项目进度会议,请准时参加。', time: '2026-03-10 15:00', unread: false },
])
const workflows = ref<Workflow[]>([
{ id: 1, name: '请假审批流程', status: '进行中', creator: '张三', createTime: '2026-03-09' },
{ id: 2, name: '采购申请流程', status: '已完成', creator: '李四', createTime: '2026-03-08' },
{ id: 3, name: '项目立项流程', status: '草稿', creator: '王五', createTime: '2026-03-10' },
{ id: 4, name: '费用报销流程', status: '进行中', creator: '赵六', createTime: '2026-03-07' },
])
const projects = ref<Project[]>([
{ id: 1, name: 'ERP 系统升级', status: '进行中', progress: 65, manager: '张三' },
{ id: 2, name: 'CRM 客户管理系统', status: '规划中', progress: 20, manager: '李四' },
{ id: 3, name: '数据分析平台', status: '已完成', progress: 100, manager: '王五' },
{ id: 4, name: '移动办公 APP', status: '进行中', progress: 45, manager: '赵六' },
])
// 计算属性:获取当前选中的项目
const selectedMessage = computed(() =>
messages.value.find(m => m.id === selectedMessageId.value)
)
const selectedWorkflow = computed(() =>
workflows.value.find(w => w.id === selectedWorkflowId.value)
)
const selectedProject = computed(() =>
projects.value.find(p => p.id === selectedProjectId.value)
)
// 切换功能
const selectFunction = (func: FunctionType) => {
currentFunction.value = func
// 清空其他选中项
selectedMessageId.value = null
selectedWorkflowId.value = null
selectedProjectId.value = null
}
// 选择列表项
const selectMessage = (id: number) => {
selectedMessageId.value = id
// 标记为已读
const msg = messages.value.find(m => m.id === id)
if (msg) msg.unread = false
}
const selectWorkflow = (id: number) => {
selectedWorkflowId.value = id
}
const selectProject = (id: number) => {
selectedProjectId.value = id
}
// 操作处理函数
const handleMessageAction = (action: string, messageId: number) => {
console.log(`处理消息 ${messageId}: ${action}`)
// TODO: 实现具体业务逻辑
}
const handleWorkflowAction = (action: string, workflowId: number) => {
console.log(`处理流程 ${workflowId}: ${action}`)
// TODO: 实现具体业务逻辑
}
const handleProjectAction = (action: string, projectId: number) => {
console.log(`处理项目 ${projectId}: ${action}`)
// TODO: 实现具体业务逻辑
}
return {
// 状态
currentFunction,
selectedMessageId,
selectedWorkflowId,
selectedProjectId,
// 数据
messages,
workflows,
projects,
// 计算属性
selectedMessage,
selectedWorkflow,
selectedProject,
// 方法
selectFunction,
selectMessage,
selectWorkflow,
selectProject,
handleMessageAction,
handleWorkflowAction,
handleProjectAction,
}
}
+27
View File
@@ -0,0 +1,27 @@
import {
isPermissionGranted,
Options as NotificationOptions,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
export function useNotification(options: NotificationOptions) {
const send = async (replaceOptions?: Partial<NotificationOptions>) => {
// 你有发送通知的权限吗?
let permissionGranted = await isPermissionGranted();
// 如果没有,我们需要请求它
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
// 一旦获得许可,我们就可以发送通知
if (permissionGranted) {
sendNotification({ ...options, ...replaceOptions });
}
};
return send
}
+124
View File
@@ -0,0 +1,124 @@
import { ref, computed, onMounted } from 'vue'
import {
getWorkflows as apiGetWorkflows,
createWorkflow as apiCreateWorkflow,
updateWorkflow as apiUpdateWorkflow,
deleteWorkflow as apiDeleteWorkflow
} from '@/api/workflow'
export interface Workflow {
id: number
name: string
status: '进行中' | '已完成' | '草稿' | '已暂停'
creator: string
createTime: string
definition?: string
updatedTime?: string
}
export function useWorkflow() {
const selectedWorkflowId = ref<number | null>(null)
const workflows = ref<Workflow[]>([])
const isLoading = ref(false)
// 加载工作流列表
const loadWorkflows = async () => {
isLoading.value = true
try {
workflows.value = await apiGetWorkflows()
} catch (error) {
console.error('加载工作流失败:', error)
} finally {
isLoading.value = false
}
}
// 创建工作流
const createWorkflow = async (workflowData: Omit<Workflow, 'id'>) => {
const id = await apiCreateWorkflow(workflowData)
if (id) {
await loadWorkflows()
return id
}
return null
}
// 更新工作流
const updateWorkflow = async (id: number, workflowData: Partial<Workflow>) => {
const success = await apiUpdateWorkflow(id, workflowData)
if (success) {
await loadWorkflows()
}
return success
}
// 删除工作流
const deleteWorkflow = async (id: number) => {
const success = await apiDeleteWorkflow(id)
if (success) {
if (selectedWorkflowId.value === id) {
selectedWorkflowId.value = null
}
await loadWorkflows()
}
return success
}
// 计算属性:获取当前选中的工作流
const selectedWorkflow = computed(() =>
workflows.value.find(w => w.id === selectedWorkflowId.value)
)
// 选择工作流
const selectWorkflow = (id: number) => {
selectedWorkflowId.value = id
}
// 操作处理函数
const handleWorkflowAction = async (action: string, workflowId: number) => {
console.log(`处理流程 ${workflowId}: ${action}`)
switch (action) {
case 'delete':
if (confirm('确定要删除这个工作流吗?')) {
await deleteWorkflow(workflowId)
}
break
case 'edit':
// TODO: 打开编辑对话框
console.log('编辑工作流', workflowId)
break
case 'start':
// TODO: 启动工作流
console.log('启动工作流', workflowId)
break
default:
console.log('未知操作:', action)
}
}
// 初始化加载数据
onMounted(() => {
loadWorkflows()
})
return {
// 状态
selectedWorkflowId,
isLoading,
// 数据
workflows,
// 计算属性
selectedWorkflow,
// 方法
selectWorkflow,
handleWorkflowAction,
loadWorkflows,
createWorkflow,
updateWorkflow,
deleteWorkflow,
}
}
-48
View File
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
definePage({
name: 'About',
meta: {
title: 'About Page'
}
})
</script>
<template>
<main class="container">
<h1>About Page</h1>
<p>This is the about page.</p>
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink to="/about">About</RouterLink>
</nav>
</main>
</template>
<style scoped>
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
nav {
margin-top: 20px;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: none;
margin: 0 10px;
}
a:hover {
color: #535bf2;
text-decoration: underline;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<template>
<div class="drawer h-full md:drawer-open">
<input id="my-drawer-4" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<nav class="navbar bg-base-300">
<label for="my-drawer-4" aria-label="打开侧边栏" class="btn btn-square btn-ghost">
<!-- Sidebar toggle icon -->
<PanelLeftOpen class="[.drawer-toggle:checked~.drawer-content_.navbar_&]:hidden" />
<PanelLeftClose class="[.drawer-toggle:checked~.drawer-content_.navbar_&]:block hidden" />
</label>
<div class="px-4">{{ route.name }}</div>
</nav>
<!-- Navbar -->
<!-- Page content here -->
<main class="p-4 flex-1 flex flex-row gap-1">
<RouterView></RouterView>
</main>
</div>
<div class="drawer-side is-drawer-close:overflow-visible">
<label for="my-drawer-4" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex min-h-full flex-col items-start bg-base-200 is-drawer-close:w-14 is-drawer-open:w-64">
<!-- Sidebar content here -->
<SideNav />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SideNav from '@/components/home/SideNav.vue'
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue'
import { useRoute } from 'vue-router'
const route = useRoute()
definePage({
name: 'Home',
path: '/',
redirect: '/chat'
})
</script>
+324
View File
@@ -0,0 +1,324 @@
<template>
<div class="flex flex-1 gap-4 w-full h-full overflow-hidden">
<!-- 左侧自动化任务列表 -->
<div class="w-80 bg-base-200 p-4 rounded-lg flex flex-col overflow-hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">自动化任务</h3>
<button class="btn btn-primary btn-sm" @click="showCreateModal = true">
<Plus :size="16" />
新建
</button>
</div>
<!-- 搜索框 -->
<div class="form-control mb-4">
<input
v-model="searchText"
type="text"
placeholder="搜索任务..."
class="input input-bordered input-sm"
/>
</div>
<!-- 任务列表 -->
<div class="flex-1 overflow-y-auto">
<div
v-for="task in filteredTasks"
:key="task.id"
@click="selectTask(task)"
:class="[
'p-3 mb-2 rounded-lg cursor-pointer transition-colors border-l-4',
selectedTask?.id === task.id
? 'bg-primary text-primary-content border-primary'
: 'bg-base-100 hover:bg-base-300 border-transparent'
]"
>
<div class="flex justify-between items-start">
<div class="font-semibold text-sm">{{ task.name }}</div>
<span :class="getStatusBadgeClass(task.status)">{{ task.status }}</span>
</div>
<div class="text-xs opacity-70 mt-1">
{{ getTriggerTypeLabel(task.triggerType) }}
</div>
<div class="text-xs opacity-50 mt-1">
下次执行{{ task.nextRun || '未安排' }}
</div>
</div>
</div>
</div>
<!-- 右侧任务详情 -->
<div class="flex-1 bg-base-200 p-6 rounded-lg overflow-y-auto">
<div v-if="selectedTask" class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">{{ selectedTask.name }}</h2>
<div class="flex gap-2">
<button class="btn btn-success btn-sm" @click="toggleTask(true)" :disabled="selectedTask.status === '运行中'">
<Play :size="16" />
启动
</button>
<button class="btn btn-warning btn-sm" @click="toggleTask(false)" :disabled="selectedTask.status !== '运行中'">
<Pause :size="16" />
暂停
</button>
<button class="btn btn-error btn-sm" @click="deleteTask">
<Trash2 :size="16" />
删除
</button>
</div>
</div>
<!-- 基本信息 -->
<div class="card bg-base-100 p-4">
<h3 class="font-bold mb-3">基本信息</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">任务名称</span>
</label>
<input
v-model="selectedTask.name"
type="text"
class="input input-bordered input-sm"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">关联流程</span>
</label>
<select v-model="selectedTask.workflowId" class="select select-bordered select-sm">
<option value="">选择流程</option>
<option v-for="wf in workflows" :key="wf.id" :value="wf.id">
{{ wf.name }}
</option>
</select>
</div>
</div>
</div>
<!-- 触发器配置 -->
<div class="card bg-base-100 p-4">
<h3 class="font-bold mb-3">触发器配置</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">触发类型</span>
</label>
<select v-model="selectedTask.triggerType" class="select select-bordered select-sm">
<option value="cron">定时触发</option>
<option value="event">事件触发</option>
<option value="manual">手动触发</option>
</select>
</div>
<!-- Cron 表达式配置 -->
<div v-if="selectedTask.triggerType === 'cron'" class="space-y-4">
<CronEditor v-model="selectedTask.cronExpression" />
</div>
<!-- 事件触发配置 -->
<div v-if="selectedTask.triggerType === 'event'" class="space-y-4">
<EventTrigger v-model="selectedTask.eventConfig" />
</div>
</div>
<!-- 调度规则 -->
<div class="card bg-base-100 p-4">
<h3 class="font-bold mb-3">调度规则</h3>
<ScheduleManager v-model="selectedTask.schedule" />
</div>
<!-- 执行历史 -->
<div class="card bg-base-100 p-4">
<h3 class="font-bold mb-3">执行历史</h3>
<div class="text-sm opacity-70">
暂无执行记录
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center h-full">
<div class="text-center opacity-50">
<Clock :size="48" class="mx-auto mb-4 opacity-50" />
<p>请选择一个自动化任务</p>
</div>
</div>
</div>
</div>
<!-- 创建任务弹窗 -->
<dialog :class="['modal', { 'modal-open': showCreateModal }]">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">创建自动化任务</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">任务名称</span>
</label>
<input
v-model="newTaskName"
type="text"
placeholder="请输入任务名称"
class="input input-bordered"
/>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">关联流程</span>
</label>
<select v-model="newTaskWorkflowId" class="select select-bordered">
<option value="">选择流程</option>
<option v-for="wf in workflows" :key="wf.id" :value="wf.id">
{{ wf.name }}
</option>
</select>
</div>
<div class="modal-action">
<button class="btn" @click="showCreateModal = false">取消</button>
<button class="btn btn-primary" @click="createTask">创建</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Plus, Play, Pause, Trash2, Clock } from '@lucide/vue'
import CronEditor from '@/components/automation/CronEditor.vue'
import EventTrigger from '@/components/automation/EventTrigger.vue'
import ScheduleManager from '@/components/automation/ScheduleManager.vue'
import { getWorkflows } from '@/api/workflow'
import type { Workflow } from '@/composables/useWorkflow'
definePage({
name: '自动化',
})
interface AutomationTask {
id: number
name: string
workflowId: number
status: '运行中' | '已暂停' | '草稿'
triggerType: 'cron' | 'event' | 'manual'
cronExpression?: string
eventConfig?: any
schedule?: any
nextRun?: string
}
const workflows = ref<Workflow[]>([])
const selectedTask = ref<AutomationTask | null>(null)
const searchText = ref('')
const showCreateModal = ref(false)
const newTaskName = ref('')
const newTaskWorkflowId = ref<number | ''>('')
const tasks = ref<AutomationTask[]>([
{
id: 1,
name: '每日数据同步',
workflowId: 1,
status: '运行中',
triggerType: 'cron',
cronExpression: '0 0 2 * * ?',
nextRun: '2026-03-12 02:00:00',
},
{
id: 2,
name: '周报生成',
workflowId: 2,
status: '已暂停',
triggerType: 'cron',
cronExpression: '0 0 9 ? * MON',
nextRun: '未安排',
},
])
//
const filteredTasks = computed(() => {
if (!searchText.value) return tasks.value
return tasks.value.filter(task =>
task.name.toLowerCase().includes(searchText.value.toLowerCase())
)
})
//
const getTriggerTypeLabel = (type: string) => {
const labels: Record<string, string> = {
cron: '定时触发',
event: '事件触发',
manual: '手动触发',
}
return labels[type] || type
}
//
const getStatusBadgeClass = (status: string) => {
const baseClass = 'badge badge-sm'
switch (status) {
case '运行中':
return `${baseClass} badge-info`
case '已暂停':
return `${baseClass} badge-warning`
case '草稿':
return `${baseClass} badge-ghost`
default:
return baseClass
}
}
//
const selectTask = (task: AutomationTask) => {
selectedTask.value = { ...task }
}
//
const toggleTask = (start: boolean) => {
if (!selectedTask.value) return
selectedTask.value.status = start ? '运行中' : '已暂停'
// TODO: API
}
//
const deleteTask = () => {
if (!selectedTask.value) return
if (confirm(`确定要删除任务 "${selectedTask.value.name}" 吗?`)) {
tasks.value = tasks.value.filter(t => t.id !== selectedTask.value!.id)
selectedTask.value = null
}
}
//
const createTask = () => {
if (!newTaskName.value || !newTaskWorkflowId.value) {
alert('请填写完整信息')
return
}
const newTask: AutomationTask = {
id: Date.now(),
name: newTaskName.value,
workflowId: Number(newTaskWorkflowId.value),
status: '草稿',
triggerType: 'manual',
}
tasks.value.push(newTask)
showCreateModal.value = false
newTaskName.value = ''
newTaskWorkflowId.value = ''
}
onMounted(async () => {
workflows.value = await getWorkflows()
})
</script>
<style scoped>
.modal {
&.modal-open {
display: flex;
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="flex flex-1 w-full h-full overflow-hidden">
<!-- 聊天窗口 -->
<ChatWindow :bubbles="currentBubbles" @send="handleSendMessage" />
</div>
</template>
<script setup lang="ts">
import ChatWindow from '@/components/chat/ChatWindow.vue';
import { useChat } from '@/composables/useChat';
import { useNotification } from '@/composables/useNotifition';
definePage({
name: '消息',
})
const {
selectedChat,
currentBubbles,
sendMessage,
} = useChat()
//
const handleSendMessage = (content: string) => {
sendMessage(content)
}
</script>
<style scoped></style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<!-- 流程列表 -->
<div class="w-full bg-base-200 border-r border-base-300 p-4 flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">工作流列表</h3>
<button class="btn btn-sm btn-primary" @click="createNewWorkflow">
<Plus :size="16" />
新建
</button>
</div>
<!-- 搜索框 -->
<div class="form-control mb-4">
<input v-model="searchText" type="text" placeholder="搜索流程..." class="w-100 input input-bordered input-sm" />
</div>
<!-- 流程列表 -->
<div class="flex-1 overflow-auto">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<!-- 表头 -->
<thead>
<tr>
<th class="w-8"></th>
<th>流程名称</th>
<th>状态</th>
<th>创建人</th>
<th>创建时间</th>
<th class="text-right">操作</th>
</tr>
</thead>
<!-- 表体 -->
<tbody>
<tr v-for="workflow in paginatedWorkflows" :key="workflow.id" @click="handleWorkflowClick(workflow)" :class="[
'cursor-pointer hover:bg-base-300',
selectedWorkflowId === workflow.id ? 'bg-primary text-primary-content' : ''
]">
<td class="text-xs">
<div class="badge badge-xs"
:class="selectedWorkflowId === workflow.id ? 'badge-primary' : 'badge-ghost'"></div>
</td>
<td class="font-semibold">{{ workflow.name }}</td>
<td>
<span :class="getStatusBadgeClass(workflow.status)" class="badge badge-xs">
{{ workflow.status }}
</span>
</td>
<td class="text-sm opacity-70">{{ workflow.creator }}</td>
<td class="text-sm opacity-70">{{ formatDate(workflow.createTime) }}</td>
<td class="text-right">
<div class="join">
<button class="join-item btn btn-xs btn-ghost" @click.stop="editWorkflow(workflow)" title="编辑">
<Edit :size="14" />
编辑
</button>
<button class="join-item btn btn-xs btn-ghost text-error hover:text-error"
@click.stop="confirmDeleteWorkflow(workflow)" title="删除">
<Trash2 :size="14" />
删除
</button>
</div>
</td>
</tr>
<!-- 空状态 -->
<tr v-if="filteredWorkflows.length === 0">
<td colspan="6" class="text-center py-8 opacity-50">
<p>暂无工作流</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="mt-4 flex justify-center gap-2">
<div class="join">
<button class="join-item btn" :disabled="currentPage === 1" @click="currentPage--">上一页</button>
<button class="join-item btn">{{ currentPage }} / {{ totalPages }}</button>
<button class="join-item btn" :disabled="currentPage === totalPages" @click="currentPage++">下一页</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { deleteWorkflow as apiDeleteWorkflow, getWorkflows } from '@/api/workflow'
import type { Workflow } from '@/composables/useWorkflow'
import { Edit, Plus, Trash2 } from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import toast from 'vue3-hot-toast'
definePage({
name: '流程',
})
const router = useRouter()
const workflows = ref<Workflow[]>([])
const selectedWorkflowId = ref<number | null>(null)
const searchText = ref('')
const currentPage = ref(1)
const pageSize = 10
//
const filteredWorkflows = computed(() => {
if (!searchText.value) return workflows.value
return workflows.value.filter(wf =>
wf.name.toLowerCase().includes(searchText.value.toLowerCase())
)
})
//
const paginatedWorkflows = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredWorkflows.value.slice(start, end)
})
//
const totalPages = computed(() => Math.ceil(filteredWorkflows.value.length / pageSize))
//
const handleWorkflowClick = (workflow: Workflow) => {
selectedWorkflowId.value = workflow.id
}
//
const editWorkflow = (workflow: Workflow) => {
router.push(`/workflow/${workflow.id}`)
}
//
const confirmDeleteWorkflow = (workflow: Workflow) => {
if (confirm(`确定要删除工作流 "${workflow.name}" 吗?`)) {
deleteWorkflow(workflow)
}
}
//
const deleteWorkflow = async (workflow: Workflow) => {
const success = await apiDeleteWorkflow(workflow.id)
if (success) {
toast.success('删除成功')
if (selectedWorkflowId.value === workflow.id) {
selectedWorkflowId.value = null
}
await loadWorkflows()
} else {
toast.error('删除失败')
}
}
//
const createNewWorkflow = () => {
router.push('/workflow/new')
}
//
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN')
}
//
const getStatusBadgeClass = (status: string) => {
const classes: Record<string, string> = {
'进行中': 'badge-info',
'已完成': 'badge-success',
'草稿': 'badge-ghost',
'已暂停': 'badge-warning',
}
return `badge ${classes[status] || ''}`
}
//
const loadWorkflows = async () => {
workflows.value = await getWorkflows()
}
onMounted(() => {
loadWorkflows()
})
</script>
<style scoped></style>
-7
View File
@@ -1,7 +0,0 @@
<script setup lang="ts">
import HomePage from '@/components/home/index.vue'
</script>
<template>
<HomePage />
</template>
+2 -2
View File
@@ -58,9 +58,9 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { Lock, User } from '@lucide/vue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from 'lucide-vue-next'
const router = useRouter()
+40 -9
View File
@@ -26,16 +26,32 @@ declare module 'vue-router/auto-routes' {
* Route name map generated by vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<
'/',
'Home': RouteRecordInfo<
'Home',
'/',
Record<never, never>,
Record<never, never>,
| '流程'
| '消息'
| '自动化'
>,
'自动化': RouteRecordInfo<
'自动化',
'/automation',
Record<never, never>,
Record<never, never>,
| never
>,
'About': RouteRecordInfo<
'About',
'/about',
'消息': RouteRecordInfo<
'消息',
'/chat',
Record<never, never>,
Record<never, never>,
| never
>,
'流程': RouteRecordInfo<
'流程',
'/workflow',
Record<never, never>,
Record<never, never>,
| never
@@ -60,15 +76,30 @@ declare module 'vue-router/auto-routes' {
* @internal
*/
export interface _RouteFileInfoMap {
'src/pages/index.vue': {
'src/pages/home.vue': {
routes:
| '/'
| 'Home'
| '流程'
| '消息'
| '自动化'
views:
| 'default'
}
'src/pages/home/automation/index.vue': {
routes:
| '自动化'
views:
| never
}
'src/pages/about.vue': {
'src/pages/home/chat/index.vue': {
routes:
| 'About'
| '消息'
views:
| never
}
'src/pages/home/workflow/index.vue': {
routes:
| '流程'
views:
| never
}
+63
View File
@@ -0,0 +1,63 @@
// 基础节点数据接口
export interface NodeData {
label: string
description?: string
[key: string]: any
}
// 表单节点数据
export interface FormNodeData extends NodeData {
formId: string
formName: string
formFields: FormField[]
}
export interface FormField {
id: string
name: string
type: 'input' | 'select' | 'textarea' | 'checkbox' | 'radio' | 'date'
label: string
required?: boolean
options?: { label: string; value: any }[]
defaultValue?: any
placeholder?: string
}
// 定时节点数据
export interface CronNodeData extends NodeData {
cronExpression: string
timezone?: string
startDate?: string
endDate?: string
loopType: 'once' | 'daily' | 'weekly' | 'monthly' | 'custom'
}
// SQL 节点数据
export interface SqlNodeData extends NodeData {
databaseType: 'mysql' | 'postgresql' | 'sqlite' | 'sqlserver'
connectionString?: string
sqlQuery: string
parameters?: SqlParameter[]
outputMapping?: Record<string, string>
}
export interface SqlParameter {
name: string
type: string
value: any
}
// 脚本节点数据
export interface ScriptNodeData extends NodeData {
scriptType: 'shell' | 'python' | 'javascript' | 'powershell'
scriptContent: string
workingDirectory?: string
environmentVariables?: Record<string, string>
timeout?: number
}
// 节点类型联合
export type CustomNodeData = FormNodeData | CronNodeData | SqlNodeData | ScriptNodeData
// 节点类型枚举
export type CustomNodeType = 'form' | 'cron' | 'sql' | 'script'
+58
View File
@@ -0,0 +1,58 @@
import Database from '@tauri-apps/plugin-sql'
let dbInstance: Database | null = null
export async function getDatabase(): Promise<Database> {
if (!dbInstance) {
dbInstance = await Database.load('sqlite:rolegram.db')
}
return dbInstance
}
// 初始化工作流表
export async function initWorkflowTable() {
const db = await getDatabase()
await db.execute(`
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '草稿',
creator TEXT NOT NULL,
createTime TEXT NOT NULL,
definition TEXT,
updatedTime TEXT
)
`)
}
// 初始化节点表
export async function initNodesTable() {
const db = await getDatabase()
await db.execute(`
CREATE TABLE IF NOT EXISTS workflow_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
node_type TEXT NOT NULL,
position_x REAL NOT NULL,
position_y REAL NOT NULL,
data TEXT,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
)
`)
}
// 初始化边表
export async function initEdgesTable() {
const db = await getDatabase()
await db.execute(`
CREATE TABLE IF NOT EXISTS workflow_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
source TEXT NOT NULL,
target TEXT NOT NULL,
type TEXT,
label TEXT,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
)
`)
}
+1
View File
@@ -10,6 +10,7 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [vue(), VueRouter({
dts: 'src/route-map.d.ts',
logs: true,
}), tailwindcss()],
resolve: {