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>
This commit is contained in:
beabigegg
2025-12-29 22:03:05 +08:00
parent 0ef78e13ff
commit 3108fe1dff
21 changed files with 2027 additions and 1 deletions

View File

@@ -0,0 +1,197 @@
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

View File

@@ -0,0 +1,194 @@
import { useState, useRef, DragEvent, ChangeEvent } from 'react'
import { attachmentService } from '../services/attachments'
interface AttachmentUploadProps {
taskId: string
onUploadComplete?: () => void
}
export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadProps) {
const [isDragging, setIsDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
await uploadFiles(files)
}
}
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : []
if (files.length > 0) {
await uploadFiles(files)
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const uploadFiles = async (files: File[]) => {
setUploading(true)
setError(null)
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`)
await attachmentService.uploadAttachment(taskId, file)
}
setUploadProgress(null)
onUploadComplete?.()
} catch (err: unknown) {
console.error('Upload failed:', err)
const errorMessage = err instanceof Error ? err.message : 'Upload failed'
setError(errorMessage)
} finally {
setUploading(false)
}
}
const handleClick = () => {
fileInputRef.current?.click()
}
return (
<div style={styles.container}>
<div
style={{
...styles.dropzone,
...(isDragging ? styles.dropzoneActive : {}),
...(uploading ? styles.dropzoneDisabled : {}),
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={!uploading ? handleClick : undefined}
>
<input
ref={fileInputRef}
type="file"
style={styles.hiddenInput}
onChange={handleFileSelect}
multiple
disabled={uploading}
/>
{uploading ? (
<div style={styles.uploading}>
<div style={styles.spinner}></div>
<span>{uploadProgress}</span>
</div>
) : (
<div style={styles.content}>
<span style={styles.icon}>📎</span>
<span style={styles.text}>
Drop files here or click to upload
</span>
<span style={styles.hint}>
Maximum file size: 50MB
</span>
</div>
)}
</div>
{error && (
<div style={styles.error}>{error}</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
marginBottom: '16px',
},
dropzone: {
border: '2px dashed #dee2e6',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: '#f8f9fa',
},
dropzoneActive: {
borderColor: '#007bff',
backgroundColor: '#e7f1ff',
},
dropzoneDisabled: {
cursor: 'not-allowed',
opacity: 0.7,
},
hiddenInput: {
display: 'none',
},
content: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
},
icon: {
fontSize: '32px',
},
text: {
fontSize: '14px',
color: '#495057',
fontWeight: 500,
},
hint: {
fontSize: '12px',
color: '#6c757d',
},
uploading: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
color: '#007bff',
},
spinner: {
width: '20px',
height: '20px',
border: '2px solid #007bff',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
error: {
marginTop: '8px',
padding: '8px 12px',
backgroundColor: '#f8d7da',
color: '#721c24',
borderRadius: '4px',
fontSize: '14px',
},
}
// Add keyframes for spinner animation
const styleSheet = document.createElement('style')
styleSheet.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
document.head.appendChild(styleSheet)
export default AttachmentUpload

View File

@@ -0,0 +1,64 @@
import { useState, useCallback } from 'react'
import { AttachmentUpload } from './AttachmentUpload'
import { AttachmentList } from './AttachmentList'
interface TaskAttachmentsProps {
taskId: string
title?: string
}
export function TaskAttachments({ taskId, title = 'Attachments' }: TaskAttachmentsProps) {
const [refreshKey, setRefreshKey] = useState(0)
const [expanded, setExpanded] = useState(true)
const handleRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
return (
<div style={styles.container}>
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
<span style={styles.title}>{title}</span>
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
</div>
{expanded && (
<div style={styles.content}>
<AttachmentUpload taskId={taskId} onUploadComplete={handleRefresh} />
<AttachmentList key={refreshKey} taskId={taskId} onRefresh={handleRefresh} />
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef',
marginTop: '16px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
cursor: 'pointer',
userSelect: 'none',
},
title: {
fontWeight: 600,
fontSize: '14px',
color: '#495057',
},
toggleIcon: {
fontSize: '12px',
color: '#6c757d',
},
content: {
borderTop: '1px solid #e9ecef',
padding: '16px',
},
}
export default TaskAttachments