feat: add /api/files static serving and HTML iframe preview

This commit is contained in:
Tianyang 2026-08-01 19:26:26 +08:00
parent e059734cd9
commit 7d4c7eea95
4 changed files with 44 additions and 2 deletions

View File

@ -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)

View 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")

View File

@ -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} />

View File

@ -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%;