Files
PROJECT-CONTORL/frontend/src/components/AttachmentList.tsx
beabigegg 3108fe1dff feat: implement document management module
- Backend (FastAPI):
  - Attachment and AttachmentVersion models with migration
  - FileStorageService with SHA-256 checksum validation
  - File type validation (whitelist/blacklist)
  - Full CRUD API with version control support
  - Audit trail integration for upload/download/delete
  - Configurable upload directory and file size limit

- Frontend (React + Vite):
  - AttachmentUpload component with drag & drop
  - AttachmentList component with download/delete
  - TaskAttachments combined component
  - Attachments service for API calls

- Testing:
  - 12 tests for storage service and API endpoints

- OpenSpec:
  - add-document-management change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:03:05 +08:00

198 lines
4.8 KiB
TypeScript

import { useState, useEffect } from 'react'
import { attachmentService, Attachment } from '../services/attachments'
interface AttachmentListProps {
taskId: string
onRefresh?: () => void
}
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
const [attachments, setAttachments] = useState<Attachment[]>([])
const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState<string | null>(null)
useEffect(() => {
loadAttachments()
}, [taskId])
const loadAttachments = async () => {
setLoading(true)
try {
const response = await attachmentService.listAttachments(taskId)
setAttachments(response.attachments)
} catch (error) {
console.error('Failed to load attachments:', error)
} finally {
setLoading(false)
}
}
const handleDownload = async (attachment: Attachment) => {
try {
await attachmentService.downloadAttachment(attachment.id)
} catch (error) {
console.error('Failed to download attachment:', error)
alert('Failed to download file')
}
}
const handleDelete = async (attachment: Attachment) => {
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
return
}
setDeleting(attachment.id)
try {
await attachmentService.deleteAttachment(attachment.id)
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
onRefresh?.()
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete file')
} finally {
setDeleting(null)
}
}
if (loading) {
return <div style={styles.loading}>Loading attachments...</div>
}
if (attachments.length === 0) {
return <div style={styles.empty}>No attachments</div>
}
return (
<div style={styles.container}>
{attachments.map((attachment) => (
<div key={attachment.id} style={styles.item}>
<div style={styles.itemInfo}>
<span style={styles.icon}>
{attachmentService.getFileIcon(attachment.mime_type)}
</span>
<div style={styles.details}>
<div style={styles.filename}>{attachment.filename}</div>
<div style={styles.meta}>
{attachmentService.formatFileSize(attachment.file_size)}
{attachment.current_version > 1 && (
<span style={styles.version}>v{attachment.current_version}</span>
)}
<span style={styles.uploader}>
by {attachment.uploader_name || 'Unknown'}
</span>
</div>
</div>
</div>
<div style={styles.actions}>
<button
style={styles.downloadBtn}
onClick={() => handleDownload(attachment)}
title="Download"
>
Download
</button>
<button
style={styles.deleteBtn}
onClick={() => handleDelete(attachment)}
disabled={deleting === attachment.id}
title="Delete"
>
{deleting === attachment.id ? '...' : 'Delete'}
</button>
</div>
</div>
))}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
item: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef',
},
itemInfo: {
display: 'flex',
alignItems: 'center',
gap: '12px',
flex: 1,
minWidth: 0,
},
icon: {
fontSize: '24px',
},
details: {
flex: 1,
minWidth: 0,
},
filename: {
fontWeight: 500,
fontSize: '14px',
color: '#212529',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
meta: {
fontSize: '12px',
color: '#6c757d',
display: 'flex',
gap: '8px',
marginTop: '2px',
},
version: {
backgroundColor: '#e9ecef',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '11px',
},
uploader: {
fontStyle: 'italic',
},
actions: {
display: 'flex',
gap: '8px',
},
downloadBtn: {
padding: '6px 12px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
},
deleteBtn: {
padding: '6px 12px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
},
loading: {
padding: '16px',
textAlign: 'center',
color: '#6c757d',
},
empty: {
padding: '16px',
textAlign: 'center',
color: '#6c757d',
fontSize: '14px',
},
}
export default AttachmentList