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 fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import documents, search from app.routers import documents, files, search
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,6 +19,7 @@ app.add_middleware(
) )
app.include_router(documents.router) app.include_router(documents.router)
app.include_router(files.router)
app.include_router(search.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 [isPreview, setIsPreview] = useState(true);
const isImage = contentType.startsWith('image/'); const isImage = contentType.startsWith('image/');
const isHtml = contentType === 'text/html';
const isMarkdown = contentType === 'text/markdown'; const isMarkdown = contentType === 'text/markdown';
const extensions = useMemo(() => [isMarkdown ? markdown() : html()], [isMarkdown]); 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}> <button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
{deleting ? '删除中…' : '删除'} {deleting ? '删除中…' : '删除'}
</button> </button>
{!isImage && ( {!isImage && !isHtml && (
<> <>
<button className="btn-secondary" onClick={() => setIsPreview((prev) => !prev)}> <button className="btn-secondary" onClick={() => setIsPreview((prev) => !prev)}>
{isPreview ? '编辑' : '预览'} {isPreview ? '编辑' : '预览'}
@ -141,6 +142,14 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
<div className="image-preview-area" data-testid="image-preview"> <div className="image-preview-area" data-testid="image-preview">
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} /> <img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
</div> </div>
) : isHtml ? (
<iframe
className="html-preview-area"
src={`/api/files/${documentKey}`}
title={documentKey}
sandbox="allow-scripts allow-same-origin"
data-testid="html-preview"
/>
) : isPreview ? ( ) : isPreview ? (
<div className="preview-area"> <div className="preview-area">
<MarkdownPreview content={content} isMarkdown={isMarkdown} /> <MarkdownPreview content={content} isMarkdown={isMarkdown} />

View File

@ -491,6 +491,12 @@ body {
padding: 24px; padding: 24px;
} }
.html-preview-area {
flex: 1;
border: none;
background: #fff;
}
.image-preview-area img { .image-preview-area img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;