feat: 实现项目管理功能 v0.1.1
新功能: - 项目创建:支持项目名称和本地路径绑定 - 项目列表:简洁大方的卡片式布局展示 - 项目编辑:支持项目信息修改 - 项目删除:支持项目软删除 - 路径选择:集成系统文件夹选择对话框 - 路径验证:实时验证项目路径有效性 架构设计: - 遵循 Tauri 开发规范的四层架构设计 - 基础设施层:数据库管理、文件系统操作 - 数据访问层:项目仓库模式、SQLite 集成 - 业务逻辑层:项目服务、数据验证 - 表示层:Tauri 命令、前端组件 UI/UX: - 使用 Tailwind CSS 实现简洁大方的设计风格 - 响应式布局适配不同屏幕尺寸 - 流畅的动画效果和交互反馈 - 完整的错误处理和用户提示 技术栈: - 后端:Rust + Tauri + SQLite + 四层架构 - 前端:React + TypeScript + Tailwind CSS + Zustand - 测试:Rust 单元测试 + Vitest 前端测试 - 工具:pnpm 包管理 + 类型安全保证 质量保证: - Rust 单元测试覆盖核心业务逻辑 - 前端组件测试覆盖主要 UI 组件 - TypeScript 严格模式确保类型安全 - 遵循开发规范的代码质量标准 核心特性: - 项目管理:创建、查看、编辑、删除项目 - 路径管理:自动验证、绝对路径转换 - 数据持久化:SQLite 本地数据库存储 - 状态管理:Zustand 响应式状态管理 - 错误处理:完整的错误捕获和用户反馈
This commit is contained in:
@@ -20,6 +20,19 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "chrono"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
tokio = { version = "1.0", features = ["full", "sync"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
tokio-test = "0.4"
|
||||
|
||||
|
||||
37
apps/desktop/src-tauri/src/app_state.rs
Normal file
37
apps/desktop/src-tauri/src/app_state.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use std::sync::Mutex;
|
||||
use crate::data::repositories::project_repository::ProjectRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// 应用全局状态管理
|
||||
/// 遵循 Tauri 开发规范的状态管理模式
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
pub database: Mutex<Option<Database>>,
|
||||
pub project_repository: Mutex<Option<ProjectRepository>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
database: Mutex::new(None),
|
||||
project_repository: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化数据库连接
|
||||
/// 遵循安全第一原则,确保数据库初始化的安全性
|
||||
pub fn initialize_database(&self) -> anyhow::Result<()> {
|
||||
let database = Database::new()?;
|
||||
let project_repository = ProjectRepository::new(database.get_connection())?;
|
||||
|
||||
*self.database.lock().unwrap() = Some(database);
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取项目仓库实例
|
||||
pub fn get_project_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ProjectRepository>>> {
|
||||
Ok(self.project_repository.lock().unwrap())
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/business/mod.rs
Normal file
1
apps/desktop/src-tauri/src/business/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod services;
|
||||
1
apps/desktop/src-tauri/src/business/services/mod.rs
Normal file
1
apps/desktop/src-tauri/src/business/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project_service;
|
||||
142
apps/desktop/src-tauri/src/business/services/project_service.rs
Normal file
142
apps/desktop/src-tauri/src/business/services/project_service.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest};
|
||||
use crate::data::repositories::project_repository::ProjectRepository;
|
||||
use crate::infrastructure::file_system::FileSystemService;
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// 项目业务服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct ProjectService;
|
||||
|
||||
impl ProjectService {
|
||||
/// 创建新项目
|
||||
/// 遵循安全第一原则,验证输入数据和文件系统权限
|
||||
pub fn create_project(
|
||||
repository: &ProjectRepository,
|
||||
request: CreateProjectRequest,
|
||||
) -> Result<Project> {
|
||||
// 验证请求数据
|
||||
request.validate().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// 验证路径
|
||||
if !FileSystemService::is_valid_project_directory(&request.path)? {
|
||||
return Err(anyhow!("无效的项目路径或没有访问权限"));
|
||||
}
|
||||
|
||||
// 获取绝对路径
|
||||
let absolute_path = FileSystemService::get_absolute_path(&request.path)?;
|
||||
|
||||
// 检查路径是否已被使用
|
||||
if repository.path_exists(&absolute_path, None)? {
|
||||
return Err(anyhow!("该路径已被其他项目使用"));
|
||||
}
|
||||
|
||||
// 创建项目实例
|
||||
let project = Project::new(
|
||||
request.name,
|
||||
absolute_path.clone(),
|
||||
request.description,
|
||||
);
|
||||
|
||||
// 验证项目数据
|
||||
project.validate().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// 创建项目目录结构
|
||||
FileSystemService::create_project_structure(&absolute_path)?;
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&project)?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// 获取所有活跃项目
|
||||
pub fn get_all_projects(repository: &ProjectRepository) -> Result<Vec<Project>> {
|
||||
let projects = repository.find_all_active()?;
|
||||
|
||||
// 验证项目路径是否仍然有效
|
||||
let mut valid_projects = Vec::new();
|
||||
for project in projects {
|
||||
if FileSystemService::validate_path(&project.path).unwrap_or(false) {
|
||||
valid_projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid_projects)
|
||||
}
|
||||
|
||||
/// 根据ID获取项目
|
||||
pub fn get_project_by_id(
|
||||
repository: &ProjectRepository,
|
||||
id: &str,
|
||||
) -> Result<Option<Project>> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(anyhow!("项目ID不能为空"));
|
||||
}
|
||||
|
||||
let project = repository.find_by_id(id)?;
|
||||
|
||||
// 验证项目路径是否仍然有效
|
||||
if let Some(ref proj) = project {
|
||||
if !FileSystemService::validate_path(&proj.path).unwrap_or(false) {
|
||||
return Err(anyhow!("项目路径不存在或无法访问"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// 更新项目
|
||||
pub fn update_project(
|
||||
repository: &ProjectRepository,
|
||||
id: &str,
|
||||
request: UpdateProjectRequest,
|
||||
) -> Result<Project> {
|
||||
// 验证请求数据
|
||||
request.validate().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// 获取现有项目
|
||||
let mut project = repository.find_by_id(id)?
|
||||
.ok_or_else(|| anyhow!("项目不存在"))?;
|
||||
|
||||
// 更新项目信息
|
||||
project.update(request.name, request.description);
|
||||
|
||||
// 验证更新后的项目数据
|
||||
project.validate().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// 保存更新
|
||||
repository.update(&project)?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// 删除项目
|
||||
pub fn delete_project(repository: &ProjectRepository, id: &str) -> Result<()> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(anyhow!("项目ID不能为空"));
|
||||
}
|
||||
|
||||
// 检查项目是否存在
|
||||
let project = repository.find_by_id(id)?
|
||||
.ok_or_else(|| anyhow!("项目不存在"))?;
|
||||
|
||||
// 执行软删除
|
||||
repository.delete(&project.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证项目路径
|
||||
pub fn validate_project_path(path: &str) -> Result<bool> {
|
||||
if path.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
FileSystemService::is_valid_project_directory(path)
|
||||
}
|
||||
|
||||
/// 获取目录名称作为默认项目名
|
||||
pub fn get_default_project_name(path: &str) -> Result<String> {
|
||||
FileSystemService::get_directory_name(path)
|
||||
}
|
||||
}
|
||||
92
apps/desktop/src-tauri/src/config.rs
Normal file
92
apps/desktop/src-tauri/src/config.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 应用配置结构
|
||||
/// 遵循 Tauri 开发规范的配置管理模式
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub auto_save: bool,
|
||||
pub window_size: WindowSize,
|
||||
pub recent_projects: Vec<String>,
|
||||
pub default_project_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct WindowSize {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
AppConfig {
|
||||
theme: "light".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
auto_save: true,
|
||||
window_size: WindowSize {
|
||||
width: 1200.0,
|
||||
height: 800.0,
|
||||
},
|
||||
recent_projects: Vec::new(),
|
||||
default_project_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// 加载配置文件
|
||||
pub fn load() -> Self {
|
||||
let config_path = Self::get_config_path();
|
||||
|
||||
if config_path.exists() {
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str(&content) {
|
||||
Ok(config) => config,
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存配置文件
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let config_path = Self::get_config_path();
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(config_path, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取配置文件路径
|
||||
fn get_config_path() -> PathBuf {
|
||||
// 使用标准的应用数据目录
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
data_dir.join("mixvideo").join("config.json")
|
||||
} else {
|
||||
PathBuf::from(".").join("config.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加最近项目
|
||||
pub fn add_recent_project(&mut self, project_path: String) {
|
||||
self.recent_projects.retain(|p| p != &project_path);
|
||||
self.recent_projects.insert(0, project_path);
|
||||
|
||||
// 保持最近项目列表不超过10个
|
||||
if self.recent_projects.len() > 10 {
|
||||
self.recent_projects.truncate(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src-tauri/src/data/mod.rs
Normal file
2
apps/desktop/src-tauri/src/data/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod models;
|
||||
pub mod repositories;
|
||||
1
apps/desktop/src-tauri/src/data/models/mod.rs
Normal file
1
apps/desktop/src-tauri/src/data/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project;
|
||||
192
apps/desktop/src-tauri/src/data/models/project.rs
Normal file
192
apps/desktop/src-tauri/src/data/models/project.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 项目实体模型
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// 创建新项目实例
|
||||
pub fn new(name: String, path: String, description: Option<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
path,
|
||||
description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新项目信息
|
||||
pub fn update(&mut self, name: Option<String>, description: Option<String>) {
|
||||
if let Some(name) = name {
|
||||
self.name = name;
|
||||
}
|
||||
if let Some(description) = description {
|
||||
self.description = Some(description);
|
||||
}
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 验证项目数据
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.name.len() > 100 {
|
||||
return Err("项目名称不能超过100个字符".to_string());
|
||||
}
|
||||
|
||||
if self.path.trim().is_empty() {
|
||||
return Err("项目路径不能为空".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref desc) = self.description {
|
||||
if desc.len() > 500 {
|
||||
return Err("项目描述不能超过500个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建项目请求模型
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProjectRequest {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateProjectRequest {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.name.len() > 100 {
|
||||
return Err("项目名称不能超过100个字符".to_string());
|
||||
}
|
||||
|
||||
if self.path.trim().is_empty() {
|
||||
return Err("项目路径不能为空".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref desc) = self.description {
|
||||
if desc.len() > 500 {
|
||||
return Err("项目描述不能超过500个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新项目请求模型
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProjectRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateProjectRequest {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(ref name) = self.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
if name.len() > 100 {
|
||||
return Err("项目名称不能超过100个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref desc) = self.description {
|
||||
if desc.len() > 500 {
|
||||
return Err("项目描述不能超过500个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_project_creation() {
|
||||
let project = Project::new(
|
||||
"Test Project".to_string(),
|
||||
"/path/to/project".to_string(),
|
||||
Some("Test description".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(project.name, "Test Project");
|
||||
assert_eq!(project.path, "/path/to/project");
|
||||
assert_eq!(project.description, Some("Test description".to_string()));
|
||||
assert!(project.is_active);
|
||||
assert!(!project.id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_validation() {
|
||||
let mut project = Project::new(
|
||||
"Valid Project".to_string(),
|
||||
"/valid/path".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
// 有效项目应该通过验证
|
||||
assert!(project.validate().is_ok());
|
||||
|
||||
// 空名称应该失败
|
||||
project.name = "".to_string();
|
||||
assert!(project.validate().is_err());
|
||||
|
||||
// 过长名称应该失败
|
||||
project.name = "a".repeat(101);
|
||||
assert!(project.validate().is_err());
|
||||
|
||||
// 空路径应该失败
|
||||
project.name = "Valid Name".to_string();
|
||||
project.path = "".to_string();
|
||||
assert!(project.validate().is_err());
|
||||
|
||||
// 过长描述应该失败
|
||||
project.path = "/valid/path".to_string();
|
||||
project.description = Some("a".repeat(501));
|
||||
assert!(project.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_project_request_validation() {
|
||||
let valid_request = CreateProjectRequest {
|
||||
name: "Valid Project".to_string(),
|
||||
path: "/valid/path".to_string(),
|
||||
description: Some("Valid description".to_string()),
|
||||
};
|
||||
assert!(valid_request.validate().is_ok());
|
||||
|
||||
let invalid_name_request = CreateProjectRequest {
|
||||
name: "".to_string(),
|
||||
path: "/valid/path".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(invalid_name_request.validate().is_err());
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/data/repositories/mod.rs
Normal file
1
apps/desktop/src-tauri/src/data/repositories/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project_repository;
|
||||
@@ -0,0 +1,163 @@
|
||||
use rusqlite::{Connection, Result, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::data::models::project::Project;
|
||||
|
||||
/// 项目数据仓库
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计
|
||||
pub struct ProjectRepository {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectRepository {
|
||||
/// 创建新的项目仓库实例
|
||||
pub fn new(connection: Arc<Mutex<Connection>>) -> Result<Self> {
|
||||
Ok(ProjectRepository { connection })
|
||||
}
|
||||
|
||||
/// 创建项目
|
||||
pub fn create(&self, project: &Project) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, path, description, created_at, updated_at, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
[
|
||||
project.id.as_str(),
|
||||
project.name.as_str(),
|
||||
project.path.as_str(),
|
||||
project.description.as_deref().unwrap_or(""),
|
||||
project.created_at.to_rfc3339().as_str(),
|
||||
project.updated_at.to_rfc3339().as_str(),
|
||||
project.is_active.to_string().as_str(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID获取项目
|
||||
pub fn find_by_id(&self, id: &str) -> Result<Option<Project>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, path, description, created_at, updated_at, is_active
|
||||
FROM projects WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let result = stmt.query_row([id], |row| {
|
||||
Ok(self.row_to_project(row)?)
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(project) => Ok(Some(project)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据路径获取项目
|
||||
pub fn find_by_path(&self, path: &str) -> Result<Option<Project>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, path, description, created_at, updated_at, is_active
|
||||
FROM projects WHERE path = ?1"
|
||||
)?;
|
||||
|
||||
let result = stmt.query_row([path], |row| {
|
||||
Ok(self.row_to_project(row)?)
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(project) => Ok(Some(project)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有活跃项目
|
||||
pub fn find_all_active(&self) -> Result<Vec<Project>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, path, description, created_at, updated_at, is_active
|
||||
FROM projects WHERE is_active = 1 ORDER BY updated_at DESC"
|
||||
)?;
|
||||
|
||||
let project_iter = stmt.query_map([], |row| {
|
||||
Ok(self.row_to_project(row)?)
|
||||
})?;
|
||||
|
||||
let mut projects = Vec::new();
|
||||
for project in project_iter {
|
||||
projects.push(project?);
|
||||
}
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
/// 更新项目
|
||||
pub fn update(&self, project: &Project) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, description = ?2, updated_at = ?3
|
||||
WHERE id = ?4",
|
||||
[
|
||||
project.name.as_str(),
|
||||
project.description.as_deref().unwrap_or(""),
|
||||
project.updated_at.to_rfc3339().as_str(),
|
||||
project.id.as_str(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除项目(软删除)
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE projects SET is_active = 0, updated_at = ?1 WHERE id = ?2",
|
||||
[Utc::now().to_rfc3339().as_str(), id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查路径是否已存在
|
||||
pub fn path_exists(&self, path: &str, exclude_id: Option<&str>) -> Result<bool> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let count: i64 = if let Some(id) = exclude_id {
|
||||
let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND id != ?2 AND is_active = 1")?;
|
||||
stmt.query_row([path, id], |row| row.get(0))?
|
||||
} else {
|
||||
let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND is_active = 1")?;
|
||||
stmt.query_row([path], |row| row.get(0))?
|
||||
};
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 将数据库行转换为项目对象
|
||||
fn row_to_project(&self, row: &Row) -> Result<Project> {
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let updated_at_str: String = row.get(5)?;
|
||||
let is_active_str: String = row.get(6)?;
|
||||
|
||||
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "created_at".to_string(), rusqlite::types::Type::Text))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
|
||||
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "updated_at".to_string(), rusqlite::types::Type::Text))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let description: String = row.get(3)?;
|
||||
let description = if description.is_empty() { None } else { Some(description) };
|
||||
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_active: is_active_str == "1" || is_active_str.to_lowercase() == "true",
|
||||
})
|
||||
}
|
||||
}
|
||||
104
apps/desktop/src-tauri/src/infrastructure/database.rs
Normal file
104
apps/desktop/src-tauri/src/infrastructure/database.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use rusqlite::{Connection, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// 数据库管理器
|
||||
/// 遵循 Tauri 开发规范的数据库设计模式
|
||||
pub struct Database {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 创建新的数据库实例
|
||||
/// 遵循安全第一原则,确保数据库文件的安全存储
|
||||
pub fn new() -> Result<Self> {
|
||||
let db_path = Self::get_database_path();
|
||||
|
||||
// 确保数据库目录存在
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
|
||||
Some(format!("Failed to create database directory: {}", e)),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let connection = Connection::open(db_path)?;
|
||||
|
||||
// 启用外键约束
|
||||
connection.execute("PRAGMA foreign_keys = ON", [])?;
|
||||
|
||||
let database = Database {
|
||||
connection: Arc::new(Mutex::new(connection)),
|
||||
};
|
||||
|
||||
// 初始化数据库表
|
||||
database.initialize_tables()?;
|
||||
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
/// 获取数据库连接
|
||||
pub fn get_connection(&self) -> Arc<Mutex<Connection>> {
|
||||
Arc::clone(&self.connection)
|
||||
}
|
||||
|
||||
/// 初始化数据库表结构
|
||||
/// 遵循模块化设计原则,清晰的表结构定义
|
||||
fn initialize_tables(&self) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
// 创建项目表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建项目配置表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
config_key TEXT NOT NULL,
|
||||
config_value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, config_key)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取数据库文件路径
|
||||
/// 遵循安全存储原则,将数据库存储在应用数据目录
|
||||
fn get_database_path() -> PathBuf {
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
data_dir.join("mixvideo").join("mixvideo.db")
|
||||
} else {
|
||||
PathBuf::from(".").join("mixvideo.db")
|
||||
}
|
||||
}
|
||||
}
|
||||
170
apps/desktop/src-tauri/src/infrastructure/file_system.rs
Normal file
170
apps/desktop/src-tauri/src/infrastructure/file_system.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
|
||||
/// 文件系统操作工具
|
||||
/// 遵循 Tauri 开发规范的文件系统安全操作
|
||||
pub struct FileSystemService;
|
||||
|
||||
impl FileSystemService {
|
||||
/// 验证路径是否存在且可访问
|
||||
pub fn validate_path(path: &str) -> Result<bool> {
|
||||
let path = Path::new(path);
|
||||
Ok(path.exists() && path.is_dir())
|
||||
}
|
||||
|
||||
/// 获取路径的绝对路径
|
||||
pub fn get_absolute_path(path: &str) -> Result<String> {
|
||||
let path = Path::new(path);
|
||||
let absolute_path = path.canonicalize()?;
|
||||
Ok(absolute_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 检查路径是否为有效的项目目录
|
||||
pub fn is_valid_project_directory(path: &str) -> Result<bool> {
|
||||
let path = Path::new(path);
|
||||
|
||||
// 检查路径是否存在且为目录
|
||||
if !path.exists() || !path.is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 检查是否有读写权限
|
||||
let metadata = path.metadata()?;
|
||||
if metadata.permissions().readonly() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取目录名称
|
||||
pub fn get_directory_name(path: &str) -> Result<String> {
|
||||
let path = Path::new(path);
|
||||
match path.file_name() {
|
||||
Some(name) => Ok(name.to_string_lossy().to_string()),
|
||||
None => Err(anyhow::anyhow!("Invalid directory path")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建项目目录结构
|
||||
pub fn create_project_structure(project_path: &str) -> Result<()> {
|
||||
let base_path = Path::new(project_path);
|
||||
|
||||
// 创建基本目录结构
|
||||
let directories = [
|
||||
"assets",
|
||||
"output",
|
||||
"temp",
|
||||
"config",
|
||||
];
|
||||
|
||||
for dir in &directories {
|
||||
let dir_path = base_path.join(dir);
|
||||
if !dir_path.exists() {
|
||||
std::fs::create_dir_all(&dir_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建项目配置文件
|
||||
let config_file = base_path.join("mixvideo.project.json");
|
||||
if !config_file.exists() {
|
||||
let default_config = serde_json::json!({
|
||||
"version": "0.1.0",
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
"settings": {
|
||||
"auto_save": true,
|
||||
"backup_enabled": true
|
||||
}
|
||||
});
|
||||
std::fs::write(config_file, serde_json::to_string_pretty(&default_config)?)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否为现有的 MixVideo 项目
|
||||
pub fn is_mixvideo_project(path: &str) -> bool {
|
||||
let project_file = Path::new(path).join("mixvideo.project.json");
|
||||
project_file.exists()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_validate_path() {
|
||||
// 测试存在的目录
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().to_str().unwrap();
|
||||
assert!(FileSystemService::validate_path(path).unwrap());
|
||||
|
||||
// 测试不存在的路径
|
||||
assert!(!FileSystemService::validate_path("/non/existent/path").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_absolute_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().to_str().unwrap();
|
||||
let absolute_path = FileSystemService::get_absolute_path(path).unwrap();
|
||||
assert!(absolute_path.len() > path.len() || absolute_path == path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_project_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().to_str().unwrap();
|
||||
|
||||
// 有效目录应该返回 true
|
||||
assert!(FileSystemService::is_valid_project_directory(path).unwrap());
|
||||
|
||||
// 不存在的路径应该返回 false
|
||||
assert!(!FileSystemService::is_valid_project_directory("/non/existent/path").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_directory_name() {
|
||||
assert_eq!(
|
||||
FileSystemService::get_directory_name("/path/to/project").unwrap(),
|
||||
"project"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
FileSystemService::get_directory_name("C:\\Users\\test\\project").unwrap(),
|
||||
"project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_project_structure() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let project_path = temp_dir.path().to_str().unwrap();
|
||||
|
||||
FileSystemService::create_project_structure(project_path).unwrap();
|
||||
|
||||
// 检查目录是否创建
|
||||
assert!(temp_dir.path().join("assets").exists());
|
||||
assert!(temp_dir.path().join("output").exists());
|
||||
assert!(temp_dir.path().join("temp").exists());
|
||||
assert!(temp_dir.path().join("config").exists());
|
||||
|
||||
// 检查配置文件是否创建
|
||||
assert!(temp_dir.path().join("mixvideo.project.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_mixvideo_project() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let project_path = temp_dir.path().to_str().unwrap();
|
||||
|
||||
// 初始状态不是 MixVideo 项目
|
||||
assert!(!FileSystemService::is_mixvideo_project(project_path));
|
||||
|
||||
// 创建项目结构后应该是 MixVideo 项目
|
||||
FileSystemService::create_project_structure(project_path).unwrap();
|
||||
assert!(FileSystemService::is_mixvideo_project(project_path));
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src-tauri/src/infrastructure/mod.rs
Normal file
2
apps/desktop/src-tauri/src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod database;
|
||||
pub mod file_system;
|
||||
@@ -1,14 +1,50 @@
|
||||
// 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)
|
||||
}
|
||||
// 四层架构模块定义
|
||||
pub mod infrastructure;
|
||||
pub mod data;
|
||||
pub mod business;
|
||||
pub mod presentation;
|
||||
|
||||
// 应用状态和配置
|
||||
pub mod app_state;
|
||||
pub mod config;
|
||||
|
||||
use app_state::AppState;
|
||||
use presentation::commands;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::project_commands::create_project,
|
||||
commands::project_commands::get_all_projects,
|
||||
commands::project_commands::get_project_by_id,
|
||||
commands::project_commands::update_project,
|
||||
commands::project_commands::delete_project,
|
||||
commands::project_commands::validate_project_path,
|
||||
commands::project_commands::get_default_project_name,
|
||||
commands::system_commands::select_directory,
|
||||
commands::system_commands::get_app_info,
|
||||
commands::system_commands::validate_directory,
|
||||
commands::system_commands::get_directory_name
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化应用状态
|
||||
let app_handle = app.handle();
|
||||
let state: tauri::State<AppState> = app_handle.state();
|
||||
|
||||
// 初始化数据库
|
||||
if let Err(e) = state.initialize_database() {
|
||||
eprintln!("Failed to initialize database: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
2
apps/desktop/src-tauri/src/presentation/commands/mod.rs
Normal file
2
apps/desktop/src-tauri/src/presentation/commands/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod project_commands;
|
||||
pub mod system_commands;
|
||||
@@ -0,0 +1,99 @@
|
||||
use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::project_service::ProjectService;
|
||||
use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest};
|
||||
|
||||
/// 创建项目命令
|
||||
/// 遵循 Tauri 开发规范的命令设计模式
|
||||
#[command]
|
||||
pub async fn create_project(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateProjectRequest,
|
||||
) -> Result<Project, String> {
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
ProjectService::create_project(repository, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取所有项目命令
|
||||
#[command]
|
||||
pub async fn get_all_projects(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<Project>, String> {
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
ProjectService::get_all_projects(repository)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据ID获取项目命令
|
||||
#[command]
|
||||
pub async fn get_project_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<Project>, String> {
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
ProjectService::get_project_by_id(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新项目命令
|
||||
#[command]
|
||||
pub async fn update_project(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
request: UpdateProjectRequest,
|
||||
) -> Result<Project, String> {
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
ProjectService::update_project(repository, &id, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除项目命令
|
||||
#[command]
|
||||
pub async fn delete_project(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_project_repository()
|
||||
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("项目仓库未初始化")?;
|
||||
|
||||
ProjectService::delete_project(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 验证项目路径命令
|
||||
#[command]
|
||||
pub async fn validate_project_path(path: String) -> Result<bool, String> {
|
||||
ProjectService::validate_project_path(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取默认项目名称命令
|
||||
#[command]
|
||||
pub async fn get_default_project_name(path: String) -> Result<String, String> {
|
||||
ProjectService::get_default_project_name(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use tauri::{command, AppHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 应用信息结构
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
/// 选择目录命令
|
||||
/// 遵循 Tauri 开发规范的系统集成设计
|
||||
#[command]
|
||||
pub fn select_directory(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
// 使用同步方式打开文件夹选择对话框
|
||||
let dialog = app.dialog().file().set_title("选择项目目录");
|
||||
|
||||
// 创建一个简单的阻塞实现
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
dialog.pick_folder(move |folder_path| {
|
||||
let _ = tx.send(folder_path);
|
||||
});
|
||||
|
||||
// 等待结果,设置超时
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||
Ok(Some(path)) => {
|
||||
let path_str = path.to_string();
|
||||
Ok(Some(path_str))
|
||||
}
|
||||
Ok(None) => Ok(None),
|
||||
Err(_) => Err("对话框操作超时或失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取应用信息命令
|
||||
#[command]
|
||||
pub async fn get_app_info() -> Result<AppInfo, String> {
|
||||
Ok(AppInfo {
|
||||
name: "MixVideo Desktop".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 验证目录是否存在命令
|
||||
#[command]
|
||||
pub async fn validate_directory(path: String) -> Result<bool, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&path);
|
||||
Ok(path.exists() && path.is_dir())
|
||||
}
|
||||
|
||||
/// 获取目录名称命令
|
||||
#[command]
|
||||
pub async fn get_directory_name(path: String) -> Result<String, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&path);
|
||||
match path.file_name() {
|
||||
Some(name) => Ok(name.to_string_lossy().to_string()),
|
||||
None => Err("无效的目录路径".to_string()),
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/presentation/mod.rs
Normal file
1
apps/desktop/src-tauri/src/presentation/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod commands;
|
||||
@@ -28,6 +28,7 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
Reference in New Issue
Block a user