feat: add /api/files static serving and HTML iframe preview
This commit is contained in:
parent
e059734cd9
commit
7d4c7eea95
@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import documents, search
|
||||
from app.routers import documents, files, search
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -19,6 +19,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(documents.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
|
||||
|
||||
26
backend/app/routers/files.py
Normal file
26
backend/app/routers/files.py
Normal file
@ -0,0 +1,26 @@
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_safe(key: str) -> Path:
|
||||
base = Path(settings.storage_dir).resolve()
|
||||
path = (base / key).resolve()
|
||||
if not path.is_relative_to(base):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/api/files/{key:path}")
|
||||
def serve_file(key: str):
|
||||
path = _resolve_safe(key)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
media_type, _ = mimetypes.guess_type(str(path))
|
||||
return FileResponse(path, media_type=media_type or "application/octet-stream")
|
||||
@ -25,6 +25,7 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
||||
const [isPreview, setIsPreview] = useState(true);
|
||||
|
||||
const isImage = contentType.startsWith('image/');
|
||||
const isHtml = contentType === 'text/html';
|
||||
const isMarkdown = contentType === 'text/markdown';
|
||||
const extensions = useMemo(() => [isMarkdown ? markdown() : html()], [isMarkdown]);
|
||||
|
||||
@ -125,7 +126,7 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
||||
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? '删除中…' : '删除'}
|
||||
</button>
|
||||
{!isImage && (
|
||||
{!isImage && !isHtml && (
|
||||
<>
|
||||
<button className="btn-secondary" onClick={() => setIsPreview((prev) => !prev)}>
|
||||
{isPreview ? '编辑' : '预览'}
|
||||
@ -141,6 +142,14 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
||||
<div className="image-preview-area" data-testid="image-preview">
|
||||
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
|
||||
</div>
|
||||
) : isHtml ? (
|
||||
<iframe
|
||||
className="html-preview-area"
|
||||
src={`/api/files/${documentKey}`}
|
||||
title={documentKey}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
data-testid="html-preview"
|
||||
/>
|
||||
) : isPreview ? (
|
||||
<div className="preview-area">
|
||||
<MarkdownPreview content={content} isMarkdown={isMarkdown} />
|
||||
|
||||
@ -491,6 +491,12 @@ body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.html-preview-area {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.image-preview-area img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user