新功能: - 项目创建:支持项目名称和本地路径绑定 - 项目列表:简洁大方的卡片式布局展示 - 项目编辑:支持项目信息修改 - 项目删除:支持项目软删除 - 路径选择:集成系统文件夹选择对话框 - 路径验证:实时验证项目路径有效性 架构设计: - 遵循 Tauri 开发规范的四层架构设计 - 基础设施层:数据库管理、文件系统操作 - 数据访问层:项目仓库模式、SQLite 集成 - 业务逻辑层:项目服务、数据验证 - 表示层:Tauri 命令、前端组件 UI/UX: - 使用 Tailwind CSS 实现简洁大方的设计风格 - 响应式布局适配不同屏幕尺寸 - 流畅的动画效果和交互反馈 - 完整的错误处理和用户提示 技术栈: - 后端:Rust + Tauri + SQLite + 四层架构 - 前端:React + TypeScript + Tailwind CSS + Zustand - 测试:Rust 单元测试 + Vitest 前端测试 - 工具:pnpm 包管理 + 类型安全保证 质量保证: - Rust 单元测试覆盖核心业务逻辑 - 前端组件测试覆盖主要 UI 组件 - TypeScript 严格模式确保类型安全 - 遵循开发规范的代码质量标准 核心特性: - 项目管理:创建、查看、编辑、删除项目 - 路径管理:自动验证、绝对路径转换 - 数据持久化:SQLite 本地数据库存储 - 状态管理:Zustand 响应式状态管理 - 错误处理:完整的错误捕获和用户反馈
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { useProjectStore } from '../store/projectStore';
|
|
import { ProjectFormData, ProjectFormErrors } from '../types/project';
|
|
import { Folder, X, AlertCircle } from 'lucide-react';
|
|
|
|
interface ProjectFormProps {
|
|
initialData?: Partial<ProjectFormData>;
|
|
onSubmit: (data: ProjectFormData) => Promise<void>;
|
|
onCancel: () => void;
|
|
isEdit?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 项目表单组件
|
|
* 遵循 Tauri 开发规范的表单设计模式
|
|
*/
|
|
export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|
initialData,
|
|
onSubmit,
|
|
onCancel,
|
|
isEdit = false
|
|
}) => {
|
|
const { isLoading, validateProjectPath, getDefaultProjectName } = useProjectStore();
|
|
|
|
const [formData, setFormData] = useState<ProjectFormData>({
|
|
name: initialData?.name || '',
|
|
path: initialData?.path || '',
|
|
description: initialData?.description || ''
|
|
});
|
|
|
|
const [errors, setErrors] = useState<ProjectFormErrors>({});
|
|
const [isValidatingPath, setIsValidatingPath] = useState(false);
|
|
|
|
// 验证表单
|
|
const validateForm = (): boolean => {
|
|
const newErrors: ProjectFormErrors = {};
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = '项目名称不能为空';
|
|
} else if (formData.name.length > 100) {
|
|
newErrors.name = '项目名称不能超过100个字符';
|
|
}
|
|
|
|
if (!formData.path.trim()) {
|
|
newErrors.path = '项目路径不能为空';
|
|
}
|
|
|
|
if (formData.description.length > 500) {
|
|
newErrors.description = '项目描述不能超过500个字符';
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// 选择目录
|
|
const handleSelectDirectory = async () => {
|
|
try {
|
|
const selectedPath = await invoke<string | null>('select_directory');
|
|
if (selectedPath) {
|
|
setFormData(prev => ({ ...prev, path: selectedPath }));
|
|
|
|
// 如果项目名称为空,尝试从路径获取默认名称
|
|
if (!formData.name.trim()) {
|
|
try {
|
|
const defaultName = await getDefaultProjectName(selectedPath);
|
|
if (defaultName) {
|
|
setFormData(prev => ({ ...prev, name: defaultName }));
|
|
}
|
|
} catch (error) {
|
|
console.warn('获取默认项目名称失败:', error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('选择目录失败:', error);
|
|
}
|
|
};
|
|
|
|
// 验证路径
|
|
const handlePathValidation = async (path: string) => {
|
|
if (!path.trim()) return;
|
|
|
|
setIsValidatingPath(true);
|
|
try {
|
|
const isValid = await validateProjectPath(path);
|
|
if (!isValid) {
|
|
setErrors(prev => ({
|
|
...prev,
|
|
path: '无效的项目路径或没有访问权限'
|
|
}));
|
|
} else {
|
|
setErrors(prev => {
|
|
const { path: _, ...rest } = prev;
|
|
return rest;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
setErrors(prev => ({
|
|
...prev,
|
|
path: '路径验证失败'
|
|
}));
|
|
} finally {
|
|
setIsValidatingPath(false);
|
|
}
|
|
};
|
|
|
|
// 路径变化时验证
|
|
useEffect(() => {
|
|
if (formData.path && !isEdit) {
|
|
const timeoutId = setTimeout(() => {
|
|
handlePathValidation(formData.path);
|
|
}, 500);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [formData.path, isEdit]);
|
|
|
|
// 处理表单提交
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await onSubmit(formData);
|
|
} catch (error) {
|
|
console.error('提交表单失败:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="modal-overlay">
|
|
<div className="modal">
|
|
<div className="modal-header">
|
|
<h2 className="text-xl font-semibold text-gray-900">
|
|
{isEdit ? '编辑项目' : '新建项目'}
|
|
</h2>
|
|
<button className="modal-close" onClick={onCancel}>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
|
<div className="space-y-2">
|
|
<label htmlFor="name" className="form-label">
|
|
项目名称 *
|
|
</label>
|
|
<input
|
|
id="name"
|
|
type="text"
|
|
className={`form-input ${errors.name ? 'error' : ''}`}
|
|
value={formData.name}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="输入项目名称"
|
|
maxLength={100}
|
|
/>
|
|
{errors.name && (
|
|
<div className="form-error">
|
|
<AlertCircle size={14} />
|
|
{errors.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="path" className="form-label">
|
|
项目路径 *
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
id="path"
|
|
type="text"
|
|
className={`form-input ${errors.path ? 'error' : ''}`}
|
|
value={formData.path}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
|
placeholder="选择或输入项目路径"
|
|
readOnly={isEdit}
|
|
/>
|
|
{!isEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary whitespace-nowrap"
|
|
onClick={handleSelectDirectory}
|
|
>
|
|
<Folder size={16} />
|
|
浏览
|
|
</button>
|
|
)}
|
|
</div>
|
|
{isValidatingPath && (
|
|
<div className="form-info">验证路径中...</div>
|
|
)}
|
|
{errors.path && (
|
|
<div className="form-error">
|
|
<AlertCircle size={14} />
|
|
{errors.path}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="description" className="form-label">
|
|
项目描述
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
className={`form-textarea ${errors.description ? 'error' : ''}`}
|
|
value={formData.description}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
placeholder="输入项目描述(可选)"
|
|
rows={3}
|
|
maxLength={500}
|
|
/>
|
|
<div className="form-hint">
|
|
{formData.description.length}/500
|
|
</div>
|
|
{errors.description && (
|
|
<div className="form-error">
|
|
<AlertCircle size={14} />
|
|
{errors.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary flex-1"
|
|
onClick={onCancel}
|
|
disabled={isLoading}
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary flex-1"
|
|
disabled={isLoading || isValidatingPath || Object.keys(errors).length > 0}
|
|
>
|
|
{isLoading ? '处理中...' : (isEdit ? '保存' : '创建')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|