|
@@ -0,0 +1,282 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * TaskForm Component
|
|
|
|
|
+ *
|
|
|
|
|
+ * Reusable form component for creating and editing tasks.
|
|
|
|
|
+ * Requirements: 2.1, 2.2
|
|
|
|
|
+ */
|
|
|
|
|
+import React, { useState, useEffect } from 'react';
|
|
|
|
|
+import { Button } from '@humansignal/ui';
|
|
|
|
|
+
|
|
|
|
|
+export interface TaskFormData {
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ project_id: string;
|
|
|
|
|
+ data: Record<string, any>;
|
|
|
|
|
+ assigned_to?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface TaskFormProps {
|
|
|
|
|
+ initialData?: Partial<TaskFormData>;
|
|
|
|
|
+ projectId?: string; // Pre-fill project_id when creating from project detail page
|
|
|
|
|
+ onSubmit: (data: TaskFormData) => Promise<void>;
|
|
|
|
|
+ onCancel: () => void;
|
|
|
|
|
+ submitLabel?: string;
|
|
|
|
|
+ isSubmitting?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export const TaskForm: React.FC<TaskFormProps> = ({
|
|
|
|
|
+ initialData,
|
|
|
|
|
+ projectId,
|
|
|
|
|
+ onSubmit,
|
|
|
|
|
+ onCancel,
|
|
|
|
|
+ submitLabel = '提交',
|
|
|
|
|
+ isSubmitting = false,
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const [formData, setFormData] = useState<TaskFormData>({
|
|
|
|
|
+ name: initialData?.name || '',
|
|
|
|
|
+ project_id: projectId || initialData?.project_id || '',
|
|
|
|
|
+ data: initialData?.data || {},
|
|
|
|
|
+ assigned_to: initialData?.assigned_to,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const [dataJson, setDataJson] = useState<string>(
|
|
|
|
|
+ JSON.stringify(initialData?.data || {}, null, 2)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
|
|
|
+
|
|
|
|
|
+ // Update form data when initialData or projectId changes
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (initialData || projectId) {
|
|
|
|
|
+ setFormData({
|
|
|
|
|
+ name: initialData?.name || '',
|
|
|
|
|
+ project_id: projectId || initialData?.project_id || '',
|
|
|
|
|
+ data: initialData?.data || {},
|
|
|
|
|
+ assigned_to: initialData?.assigned_to,
|
|
|
|
|
+ });
|
|
|
|
|
+ setDataJson(JSON.stringify(initialData?.data || {}, null, 2));
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [initialData, projectId]);
|
|
|
|
|
+
|
|
|
|
|
+ const validateForm = (): boolean => {
|
|
|
|
|
+ const errors: Record<string, string> = {};
|
|
|
|
|
+
|
|
|
|
|
+ // Validate name (required, non-empty)
|
|
|
|
|
+ if (!formData.name.trim()) {
|
|
|
|
|
+ errors.name = '任务名称不能为空';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Validate project_id (required, non-empty)
|
|
|
|
|
+ if (!formData.project_id.trim()) {
|
|
|
|
|
+ errors.project_id = '项目 ID 不能为空';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Validate data JSON
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsedData = JSON.parse(dataJson);
|
|
|
|
|
+ if (typeof parsedData !== 'object' || parsedData === null) {
|
|
|
|
|
+ errors.data = '任务数据必须是有效的 JSON 对象';
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ errors.data = '任务数据必须是有效的 JSON 格式';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setFormErrors(errors);
|
|
|
|
|
+ return Object.keys(errors).length === 0;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+
|
|
|
|
|
+ if (!validateForm()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Parse JSON data
|
|
|
|
|
+ const parsedData = JSON.parse(dataJson);
|
|
|
|
|
+
|
|
|
|
|
+ const submitData: TaskFormData = {
|
|
|
|
|
+ ...formData,
|
|
|
|
|
+ data: parsedData,
|
|
|
|
|
+ assigned_to: formData.assigned_to?.trim() || undefined,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ await onSubmit(submitData);
|
|
|
|
|
+
|
|
|
|
|
+ // Reset form on successful submission
|
|
|
|
|
+ setFormData({
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ project_id: projectId || '',
|
|
|
|
|
+ data: {},
|
|
|
|
|
+ assigned_to: undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+ setDataJson('{}');
|
|
|
|
|
+ setFormErrors({});
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ setFormErrors({ submit: error.message || '提交失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleFieldChange = (field: keyof TaskFormData, value: string) => {
|
|
|
|
|
+ setFormData((prev) => ({ ...prev, [field]: value }));
|
|
|
|
|
+ // Clear field error when user starts typing
|
|
|
|
|
+ if (formErrors[field]) {
|
|
|
|
|
+ setFormErrors((prev) => {
|
|
|
|
|
+ const newErrors = { ...prev };
|
|
|
|
|
+ delete newErrors[field];
|
|
|
|
|
+ return newErrors;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleDataJsonChange = (value: string) => {
|
|
|
|
|
+ setDataJson(value);
|
|
|
|
|
+ // Clear data error when user starts typing
|
|
|
|
|
+ if (formErrors.data) {
|
|
|
|
|
+ setFormErrors((prev) => {
|
|
|
|
|
+ const newErrors = { ...prev };
|
|
|
|
|
+ delete newErrors.data;
|
|
|
|
|
+ return newErrors;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <form onSubmit={handleSubmit} className="flex flex-col gap-comfortable">
|
|
|
|
|
+ {/* Name field */}
|
|
|
|
|
+ <div className="flex flex-col gap-tight">
|
|
|
|
|
+ <label
|
|
|
|
|
+ htmlFor="task-name"
|
|
|
|
|
+ className="text-body-medium font-semibold text-primary-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ 任务名称 <span className="text-error-foreground">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="task-name"
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={formData.name}
|
|
|
|
|
+ onChange={(e) => handleFieldChange('name', e.target.value)}
|
|
|
|
|
+ className="px-comfortable py-tight border border-neutral-border rounded-lg text-body-medium bg-primary-background text-primary-foreground focus:outline-none focus:ring-2 focus:ring-primary-border"
|
|
|
|
|
+ placeholder="输入任务名称"
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ aria-invalid={!!formErrors.name}
|
|
|
|
|
+ aria-describedby={formErrors.name ? 'name-error' : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ {formErrors.name && (
|
|
|
|
|
+ <span id="name-error" className="text-body-small text-error-foreground">
|
|
|
|
|
+ {formErrors.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Project ID field */}
|
|
|
|
|
+ <div className="flex flex-col gap-tight">
|
|
|
|
|
+ <label
|
|
|
|
|
+ htmlFor="task-project-id"
|
|
|
|
|
+ className="text-body-medium font-semibold text-primary-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ 项目 ID <span className="text-error-foreground">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="task-project-id"
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={formData.project_id}
|
|
|
|
|
+ onChange={(e) => handleFieldChange('project_id', e.target.value)}
|
|
|
|
|
+ className="px-comfortable py-tight border border-neutral-border rounded-lg text-body-small font-mono bg-primary-background text-primary-foreground focus:outline-none focus:ring-2 focus:ring-primary-border"
|
|
|
|
|
+ placeholder="输入项目 ID"
|
|
|
|
|
+ disabled={isSubmitting || !!projectId}
|
|
|
|
|
+ aria-invalid={!!formErrors.project_id}
|
|
|
|
|
+ aria-describedby={formErrors.project_id ? 'project-id-error' : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ {formErrors.project_id && (
|
|
|
|
|
+ <span id="project-id-error" className="text-body-small text-error-foreground">
|
|
|
|
|
+ {formErrors.project_id}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {projectId && (
|
|
|
|
|
+ <span className="text-body-small text-secondary-foreground">
|
|
|
|
|
+ 此任务将创建在当前项目下
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Assigned to field (optional) */}
|
|
|
|
|
+ <div className="flex flex-col gap-tight">
|
|
|
|
|
+ <label
|
|
|
|
|
+ htmlFor="task-assigned-to"
|
|
|
|
|
+ className="text-body-medium font-semibold text-primary-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ 分配给
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="task-assigned-to"
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={formData.assigned_to}
|
|
|
|
|
+ onChange={(e) => handleFieldChange('assigned_to', e.target.value)}
|
|
|
|
|
+ className="px-comfortable py-tight border border-neutral-border rounded-lg text-body-medium bg-primary-background text-primary-foreground focus:outline-none focus:ring-2 focus:ring-primary-border"
|
|
|
|
|
+ placeholder="输入用户名(可选)"
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-body-small text-secondary-foreground">
|
|
|
|
|
+ 可选字段,留空表示未分配
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Data field (JSON) */}
|
|
|
|
|
+ <div className="flex flex-col gap-tight">
|
|
|
|
|
+ <label
|
|
|
|
|
+ htmlFor="task-data"
|
|
|
|
|
+ className="text-body-medium font-semibold text-primary-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ 任务数据 (JSON) <span className="text-error-foreground">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ id="task-data"
|
|
|
|
|
+ value={dataJson}
|
|
|
|
|
+ onChange={(e) => handleDataJsonChange(e.target.value)}
|
|
|
|
|
+ className="px-comfortable py-tight border border-neutral-border rounded-lg text-body-small font-mono bg-primary-background text-primary-foreground focus:outline-none focus:ring-2 focus:ring-primary-border resize-none"
|
|
|
|
|
+ placeholder='输入任务数据 JSON,例如:{"text": "待标注的文本"}'
|
|
|
|
|
+ rows={8}
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ aria-invalid={!!formErrors.data}
|
|
|
|
|
+ aria-describedby={formErrors.data ? 'data-error' : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ {formErrors.data && (
|
|
|
|
|
+ <span id="data-error" className="text-body-small text-error-foreground">
|
|
|
|
|
+ {formErrors.data}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span className="text-body-small text-secondary-foreground">
|
|
|
|
|
+ 请输入有效的 JSON 对象,包含待标注的数据
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Submit error */}
|
|
|
|
|
+ {formErrors.submit && (
|
|
|
|
|
+ <div className="bg-error-background text-error-foreground p-comfortable rounded-lg border border-error-border">
|
|
|
|
|
+ {formErrors.submit}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Form actions */}
|
|
|
|
|
+ <div className="flex items-center justify-end gap-tight pt-comfortable">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ variant="neutral"
|
|
|
|
|
+ look="outlined"
|
|
|
|
|
+ onClick={onCancel}
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ >
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ variant="primary"
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ >
|
|
|
|
|
+ {isSubmitting ? '提交中...' : submitLabel}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|