feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points

test: implement DialogStyleContext tests for preference management and style recommendations

test: create tests for useAutoSelection hook, including countdown and pause functionality

feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications

feat: create Zustand store for managing issue submission wizard state

feat: add Zod validation schemas for issue-related API requests

feat: implement issue service for CRUD operations and validation handling

feat: define TypeScript types for issue submission and management
This commit is contained in:
catlog22
2026-02-16 11:51:21 +08:00
parent 374a1e1c2c
commit 2202c2ccfd
35 changed files with 3717 additions and 145 deletions

View File

@@ -22,64 +22,159 @@ import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
import { pullIssuesFromGitHub } from '@/lib/api';
import { pullIssuesFromGitHub, uploadAttachments } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { cn } from '@/lib/utils';
// Issue types
type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
const ISSUE_TYPE_CONFIG: Record<IssueType, { label: string; description: string; color: string }> = {
bug: { label: 'Bug', description: '功能异常或错误', color: 'bg-red-500' },
feature: { label: 'Feature', description: '新功能需求', color: 'bg-green-500' },
improvement: { label: 'Improvement', description: '现有功能改进', color: 'bg-blue-500' },
other: { label: 'Other', description: '其他类型', color: 'bg-gray-500' },
};
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority']; type?: IssueType; attachments?: File[] }) => void;
isCreating: boolean;
}) {
const { formatMessage } = useIntl();
const [title, setTitle] = useState('');
const [context, setContext] = useState('');
const [priority, setPriority] = useState<Issue['priority']>('medium');
const [type, setType] = useState<IssueType>('other');
const [attachments, setAttachments] = useState<File[]>([]);
const [dragOver, setDragOver] = useState(false);
const handleFileSelect = (files: FileList | null) => {
if (!files) return;
const validFiles = Array.from(files).filter(file => {
const validTypes = ['image/', 'text/', 'application/pdf', '.md', '.txt', '.json'];
return validTypes.some(t => file.type.includes(t) || file.name.endsWith(t));
});
setAttachments(prev => [...prev, ...validFiles]);
};
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
onSubmit({
title: title.trim(),
context: context.trim() || undefined,
priority,
type,
attachments: attachments.length > 0 ? attachments : undefined
});
// Reset
setTitle('');
setContext('');
setPriority('medium');
setType('other');
setAttachments([]);
onOpenChange(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFileSelect(e.dataTransfer.files);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<form onSubmit={handleSubmit} className="space-y-5 mt-4">
{/* 标题 */}
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
<Label className="text-sm font-medium">
{formatMessage({ id: 'issues.createDialog.labels.title' })}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
className="mt-1"
className="mt-1.5"
required
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1">{title.length}/200</p>
</div>
{/* 描述 */}
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
<Label className="text-sm font-medium">
{formatMessage({ id: 'issues.createDialog.labels.context' })}
</Label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
className="mt-1.5 w-full min-h-[120px] p-3 bg-background border border-input rounded-md text-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p className="text-xs text-muted-foreground mt-1">{context.length}/10000</p>
</div>
{/* 类型选择 */}
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
<Label className="text-sm font-medium">
{formatMessage({ id: 'issues.createDialog.labels.type', defaultMessage: '类型' })}
</Label>
<div className="grid grid-cols-4 gap-2 mt-1.5">
{(Object.keys(ISSUE_TYPE_CONFIG) as IssueType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={cn(
'px-3 py-2 rounded-md border text-sm transition-all',
type === t
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<div className="flex items-center justify-center gap-1.5">
<span className={cn('w-2 h-2 rounded-full', ISSUE_TYPE_CONFIG[t].color)} />
<span>{ISSUE_TYPE_CONFIG[t].label}</span>
</div>
</button>
))}
</div>
</div>
{/* 优先级 */}
<div>
<Label className="text-sm font-medium">
{formatMessage({ id: 'issues.createDialog.labels.priority' })}
</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
<SelectTrigger className="mt-1">
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -90,7 +185,77 @@ function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
{/* 文件/图片上传 */}
<div>
<Label className="text-sm font-medium">
{formatMessage({ id: 'issues.createDialog.labels.attachments', defaultMessage: '附件' })}
</Label>
<div
className={cn(
'mt-1.5 border-2 border-dashed rounded-lg p-4 transition-colors cursor-pointer',
dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = 'image/*,.md,.txt,.json,.pdf';
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
input.click();
}}
>
<div className="flex flex-col items-center justify-center text-muted-foreground py-2">
<svg className="w-8 h-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1">MarkdownPDF</p>
</div>
</div>
{/* 已上传文件列表 */}
{attachments.length > 0 && (
<div className="mt-2 space-y-1.5">
{attachments.map((file, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-muted/50 rounded-md">
<div className="flex items-center gap-2 min-w-0">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="w-8 h-8 object-cover rounded"
/>
) : (
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)}KB
</span>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeAttachment(index); }}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
{/* 按钮 */}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
</Button>
@@ -150,9 +315,29 @@ export function IssueHubPage() {
}
}, [refetchIssues]);
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
await createIssue(data);
setIsNewIssueOpen(false);
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority']; type?: IssueType; attachments?: File[] }) => {
try {
// Create the issue first
const newIssue = await createIssue({
title: data.title,
context: data.context,
priority: data.priority,
});
// Upload attachments if any
if (data.attachments && data.attachments.length > 0 && newIssue.id) {
try {
await uploadAttachments(newIssue.id, data.attachments);
} catch (uploadError) {
console.error('Failed to upload attachments:', uploadError);
// Don't fail the whole operation, just log the error
}
}
setIsNewIssueOpen(false);
} catch (error) {
console.error('Failed to create issue:', error);
}
};
// Queue tab handler