Files
mixvideo-v2/apps/desktop/src/components/ProjectForm.tsx
imeepos 42c5dcef8e 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 响应式状态管理
- 错误处理:完整的错误捕获和用户反馈
2025-07-13 18:46:58 +08:00

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>
);
};