Files
Claude-Code-Workflow/ccw/frontend/src/pages/IssueHubPage.tsx
catlog22 2202c2ccfd 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
2026-02-16 11:51:21 +08:00

422 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========================================
// Issue Hub Page
// ========================================
// Unified page for issues, queue, and discovery with tab navigation
import { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Plus,
RefreshCw,
Github,
Loader2,
} from 'lucide-react';
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
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, 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']; 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,
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 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-5 mt-4">
{/* 标题 */}
<div>
<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.5"
required
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1">{title.length}/200</p>
</div>
{/* 描述 */}
<div>
<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.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">
{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.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
</SelectContent>
</Select>
</div>
{/* 文件/图片上传 */}
<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>
<Button type="submit" disabled={isCreating || !title.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export function IssueHubPage() {
const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
// Issues data
const { refetch: refetchIssues, isFetching: isFetchingIssues } = useIssues();
// Queue data
const { refetch: refetchQueue, isFetching: isFetchingQueue } = useIssueQueue();
const { createIssue, isCreating } = useIssueMutations();
const setCurrentTab = (tab: IssueTab) => {
setSearchParams({ tab });
};
// Issues tab handlers
const handleIssuesRefresh = useCallback(() => {
refetchIssues();
}, [refetchIssues]);
const handleGithubSync = useCallback(async () => {
setIsGithubSyncing(true);
try {
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
console.log('GitHub sync result:', result);
await refetchIssues();
} catch (error) {
console.error('GitHub sync failed:', error);
} finally {
setIsGithubSyncing(false);
}
}, [refetchIssues]);
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
const handleQueueRefresh = useCallback(() => {
refetchQueue();
}, [refetchQueue]);
// Render action buttons based on current tab
const renderActionButtons = () => {
switch (currentTab) {
case 'issues':
case 'board':
return (
<>
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingIssues && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button variant="outline" onClick={handleGithubSync} disabled={isGithubSyncing}>
<Github className={cn('w-4 h-4 mr-2', isGithubSyncing && 'animate-spin')} />
{formatMessage({ id: 'issues.actions.github' })}
</Button>
<Button onClick={() => setIsNewIssueOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.create' })}
</Button>
</>
);
case 'queue':
return (
<>
<Button variant="outline" onClick={handleQueueRefresh} disabled={isFetchingQueue}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingQueue && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</>
);
case 'discovery':
return null; // Discovery panel has its own controls
case 'observability':
return null; // Observability panel has its own controls
case 'executions':
return null; // Execution panel has its own controls
default:
return null;
}
};
return (
<div className="space-y-6">
{/* Header and action buttons on same row */}
<div className="flex items-center justify-between">
<IssueHubHeader currentTab={currentTab} />
{/* Action buttons - dynamic based on current tab */}
{renderActionButtons() && (
<div className="flex gap-2">
{renderActionButtons()}
</div>
)}
</div>
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
{currentTab === 'board' && <IssueBoardPanel />}
{currentTab === 'queue' && <QueuePanel />}
{currentTab === 'discovery' && <DiscoveryPanel />}
{currentTab === 'observability' && <ObservabilityPanel />}
{currentTab === 'executions' && <ExecutionPanel />}
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
</div>
);
}
export default IssueHubPage;