Implement image upload and viewer in v2

This commit is contained in:
LeonspaceX
2026-01-26 21:41:28 +08:00
parent 07bf09949f
commit 0cda40060e
9 changed files with 499 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
# 这里是Sycamore whisper的后端代码喵 # 这里是Sycamore whisper的后端代码喵
# 但愿比V1写的好喵 # 但愿比V1写的好喵
from flask import Flask, jsonify, request, abort from flask import Flask, jsonify, request, abort, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import os import os
@@ -14,13 +14,18 @@ CORS(app, resources={r"/api/*": {"origins": "*"}})
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'data', 'db.sqlite') DB_PATH = os.path.join(BASE_DIR, 'data', 'db.sqlite')
IMG_DIR = os.path.join(BASE_DIR, 'data', 'img')
APP_MAX_CONTENT_LENGTH_MB = 10.0
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}' app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = APP_MAX_CONTENT_LENGTH_MB * 1024 * 1024
db = SQLAlchemy(app) db = SQLAlchemy(app)
# 全局配置变量 # 全局配置变量
NEED_AUDIT = True NEED_AUDIT = True
FILE_SIZE_LIMIT_MB = 10.0
FILE_FORMATS = ["png", "jpg", "jpeg", "gif", "webp"]
# --- 定义数据库结构 --- # --- 定义数据库结构 ---
class SiteSettings(db.Model): class SiteSettings(db.Model):
@@ -34,6 +39,8 @@ class SiteSettings(db.Model):
enable_notice = db.Column(db.Boolean, default=False) enable_notice = db.Column(db.Boolean, default=False)
need_audit = db.Column(db.Boolean, default=True) need_audit = db.Column(db.Boolean, default=True)
about = db.Column(db.Text) about = db.Column(db.Text)
file_size_limit = db.Column(db.Float) # MB
file_formats = db.Column(db.Text) # JSON list
class Identity(db.Model): class Identity(db.Model):
__tablename__ = 'identity' __tablename__ = 'identity'
@@ -74,21 +81,48 @@ class DenyWord(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
word = db.Column(db.String(255), unique=True, nullable=False) word = db.Column(db.String(255), unique=True, nullable=False)
class ImgFile(db.Model):
__tablename__ = 'img_files'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
path = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(255), nullable=True)
identity_token = db.Column(db.String(36), nullable=True)
# 初始化数据库函数 # 初始化数据库函数
def init_db(): def init_db():
if not os.path.exists('./data'): if not os.path.exists('./data'):
os.makedirs('./data') os.makedirs('./data')
if not os.path.exists(IMG_DIR):
os.makedirs(IMG_DIR)
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
def load_config(): def load_config():
global NEED_AUDIT global NEED_AUDIT, FILE_SIZE_LIMIT_MB, FILE_FORMATS, APP_MAX_CONTENT_LENGTH_MB
with app.app_context(): with app.app_context():
try: try:
settings = SiteSettings.query.first() settings = SiteSettings.query.first()
if settings: if settings:
if hasattr(settings, 'need_audit') and settings.need_audit is not None: if hasattr(settings, 'need_audit') and settings.need_audit is not None:
NEED_AUDIT = settings.need_audit NEED_AUDIT = settings.need_audit
if getattr(settings, 'file_size_limit', None) is not None:
try:
FILE_SIZE_LIMIT_MB = float(settings.file_size_limit)
APP_MAX_CONTENT_LENGTH_MB = FILE_SIZE_LIMIT_MB
app.config['MAX_CONTENT_LENGTH'] = APP_MAX_CONTENT_LENGTH_MB * 1024 * 1024
except Exception:
pass
if getattr(settings, 'file_formats', None):
try:
raw = settings.file_formats
if isinstance(raw, str):
parsed = json.loads(raw)
else:
parsed = raw
if isinstance(parsed, list):
FILE_FORMATS = [str(x).strip().lstrip('.').lower() for x in parsed if str(x).strip()]
except Exception:
pass
except Exception as e: except Exception as e:
print(f"Warning: Failed to load settings: {e}") print(f"Warning: Failed to load settings: {e}")
global DENY_WORDS_CACHE global DENY_WORDS_CACHE
@@ -321,6 +355,57 @@ def get_comments():
except Exception as e: except Exception as e:
return jsonify({"code": 2003, "data": str(e)}) return jsonify({"code": 2003, "data": str(e)})
@app.route('/api/upload_pic', methods=['POST'])
def upload_pic():
try:
if 'file' not in request.files:
return jsonify({"code": 2000, "data": "参数错误"})
file = request.files['file']
if file.filename == '':
return jsonify({"code": 2000, "data": "参数错误"})
file.seek(0, os.SEEK_END)
file_length = file.tell()
file.seek(0)
if FILE_SIZE_LIMIT_MB is not None:
limit_bytes = float(FILE_SIZE_LIMIT_MB) * 1024 * 1024
if file_length > limit_bytes:
return jsonify({"code": 2006, "data": "上传的图片超出限制大小"})
ext = os.path.splitext(file.filename)[1].lstrip('.').lower()
if not ext or (FILE_FORMATS and ext not in FILE_FORMATS):
return jsonify({"code": 2007, "data": "上传的文件类型不支持"})
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = os.path.join(IMG_DIR, filename)
file.save(filepath)
identity_token = request.form.get('identity_token') or None
name = file.filename or None
db.session.add(ImgFile(path=filename, name=name, identity_token=identity_token))
db.session.commit()
return jsonify({"code": 1001, "data": f"/api/files/{filename}"})
except Exception as e:
return jsonify({"code": 2003, "data": str(e)})
@app.route('/api/files/<path:file_name>', methods=['GET'])
def serve_file(file_name):
return send_from_directory(IMG_DIR, file_name)
@app.route('/api/file_name', methods=['GET'])
def get_file_name():
try:
path = request.args.get("path")
if not path:
return jsonify({"code": 2000, "data": "参数错误"})
record = ImgFile.query.filter_by(path=path).first()
if not record:
return jsonify({"code": 2002, "data": "数据不存在"})
return jsonify({"code": 1000, "data": record.name or ""})
except Exception as e:
return jsonify({"code": 2003, "data": str(e)})
@app.route('/api/up', methods=['POST']) @app.route('/api/up', methods=['POST'])
def upvote(): def upvote():
try: try:

View File

@@ -30,13 +30,15 @@
| ---- | ---------------------------------------------------- | | ---- | ---------------------------------------------------- |
| 1000 | 正常。适用于大多数成功的GET请求的返回。 | | 1000 | 正常。适用于大多数成功的GET请求的返回。 |
| 1001 | 正常。适用于大多数成功的POST请求的返回。 | | 1001 | 正常。适用于大多数成功的POST请求的返回。 |
| 1002 | 正常。提交内容需要等待审核。 | | 1002 | 正常。提交内容需要等待审核。 |
| 2000 | 失败。请求方式错误,例如缺少指定参数。 | | 2000 | 失败。请求方式错误,例如缺少指定参数。 |
| 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 | | 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 |
| 2002 | 失败。数据不存在。 | | 2002 | 失败。数据不存在。 |
| 2003 | 失败。服务器内部错误。 | | 2003 | 失败。服务器内部错误。 |
| 2004 | 失败。试图使用不存在的Identity。 | | 2004 | 失败。试图使用不存在的Identity。 |
| 2005 | 失败。提交内容包含违禁词。 | | 2005 | 失败。提交内容包含违禁词。 |
| 2006 | 失败。上传的图片超出限制大小。 |
| 2007 | 失败。上传的文件类型不支持。 |
| 404 | api端点不存在。 | | 404 | api端点不存在。 |
| | | | | |
| | | | | |

View File

@@ -10,6 +10,7 @@ import logging
VITE_ADD = "http://localhost:5173" VITE_ADD = "http://localhost:5173"
PY_ADD = "http://127.0.0.1:5000" PY_ADD = "http://127.0.0.1:5000"
PORT = 8080 PORT = 8080
MAX_BODY_MB = 50
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("dev_proxy") logger = logging.getLogger("dev_proxy")
@@ -97,7 +98,7 @@ async def ws_proxy_handler(request, target_url):
return ws_server return ws_server
async def main(): async def main():
app = web.Application() app = web.Application(client_max_size=MAX_BODY_MB * 1024 * 1024)
# 捕获所有路径 # 捕获所有路径
app.router.add_route('*', '/{path:.*}', proxy_handler) app.router.add_route('*', '/{path:.*}', proxy_handler)

View File

@@ -5,11 +5,12 @@ import { MainLayout } from './layouts/MainLayout';
import About from './components/About'; import About from './components/About';
import CreatePost from './components/CreatePost'; import CreatePost from './components/CreatePost';
import PostCard from './components/PostCard'; import PostCard from './components/PostCard';
import ImageViewer from './components/ImageViewer';
import { fetchArticles, type Article } from './api'; import { fetchArticles, type Article } from './api';
import { useLayout } from './context/LayoutContext'; import { useLayout } from './context/LayoutContext';
import './App.css'; import './App.css';
const Home: React.FC = () => { const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
const { refreshTrigger } = useLayout(); const { refreshTrigger } = useLayout();
const { toasterId } = useLayout(); const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
@@ -113,6 +114,7 @@ const Home: React.FC = () => {
content={article.content} content={article.content}
upvotes={article.upvotes} upvotes={article.upvotes}
downvotes={article.downvotes} downvotes={article.downvotes}
onPreviewImage={onPreviewImage}
/> />
</div> </div>
); );
@@ -124,6 +126,7 @@ const Home: React.FC = () => {
content={article.content} content={article.content}
upvotes={article.upvotes} upvotes={article.upvotes}
downvotes={article.downvotes} downvotes={article.downvotes}
onPreviewImage={onPreviewImage}
/> />
); );
})} })}
@@ -145,11 +148,29 @@ const Home: React.FC = () => {
const NotFound = () => <h1>404 Not Found</h1>; const NotFound = () => <h1>404 Not Found</h1>;
function App() { function App() {
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
const openImageViewer = (src?: string, alt?: string) => {
if (!src) return;
setImageViewer({ open: true, src, alt });
};
const closeImageViewer = () => setImageViewer({ open: false });
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<MainLayout />}> <Route
<Route index element={<Home />} /> path="/"
element={
<MainLayout
imageViewer={
imageViewer.open && imageViewer.src ? (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
) : null
}
/>
}
>
<Route index element={<Home onPreviewImage={openImageViewer} />} />
<Route path="create" element={<CreatePost />} /> <Route path="create" element={<CreatePost />} />
<Route path="about" element={<About />} /> <Route path="about" element={<About />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />

View File

@@ -290,3 +290,52 @@ export const postComment = async (commentData: PostCommentRequest): Promise<Post
throw error; throw error;
} }
}; };
export const uploadImage = async (file: File): Promise<string> => {
try {
const identity_token = await get_id_token();
const formData = new FormData();
formData.append('file', file);
if (identity_token) {
formData.append('identity_token', identity_token);
}
const response = await fetch('/api/upload_pic', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.code === 1001 && typeof json.data === 'string') {
return json.data;
}
if (json.code === 2006) {
throw new Error('UPLOAD_TOO_LARGE');
}
if (json.code === 2007) {
throw new Error('UNSUPPORTED_FORMAT');
}
throw new Error(json.data || 'Upload failed');
} catch (error) {
console.error('Failed to upload image:', error);
throw error;
}
};
export const getFileName = async (path: string): Promise<string> => {
try {
const response = await fetch(`/api/file_name?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.code === 1000 && typeof json.data === 'string') {
return json.data;
}
throw new Error('Invalid response code or missing data');
} catch (error) {
console.error('Failed to fetch file name:', error);
throw error;
}
};

View File

@@ -16,7 +16,13 @@ import {
Input, Input,
useToastController, useToastController,
Toast, Toast,
ToastTitle ToastTitle,
Dialog,
DialogSurface,
DialogBody,
DialogTitle,
DialogContent,
DialogActions
} from '@fluentui/react-components'; } from '@fluentui/react-components';
import { import {
NumberSymbol24Regular, NumberSymbol24Regular,
@@ -25,7 +31,7 @@ import {
} from '@fluentui/react-icons'; } from '@fluentui/react-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLayout } from '../context/LayoutContext'; import { useLayout } from '../context/LayoutContext';
import { saveDraft, getDraft, createPost } from '../api'; import { saveDraft, getDraft, createPost, uploadImage } from '../api';
const useStyles = makeStyles({ const useStyles = makeStyles({
container: { container: {
@@ -82,6 +88,10 @@ const useStyles = makeStyles({
}, },
tagButtonWrapper: { tagButtonWrapper: {
position: 'relative', position: 'relative',
},
imageActions: {
display: 'flex',
gap: tokens.spacingHorizontalS,
} }
}); });
@@ -128,9 +138,14 @@ const CreatePost: React.FC = () => {
// 标签输入相关状态 // 标签输入相关状态
const [showTagInput, setShowTagInput] = useState(false); const [showTagInput, setShowTagInput] = useState(false);
const [tagInputValue, setTagInputValue] = useState(""); const [tagInputValue, setTagInputValue] = useState("");
const [showImageDialog, setShowImageDialog] = useState(false);
const [showUrlDialog, setShowUrlDialog] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const tagInputRef = useRef<HTMLDivElement>(null); const tagInputRef = useRef<HTMLDivElement>(null);
const tagButtonRef = useRef<HTMLButtonElement>(null); const tagButtonRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorApiRef = useRef<any>(null);
const valueRef = useRef<string>(""); const valueRef = useRef<string>("");
const autoSaveIntervalRef = useRef<number | null>(null); const autoSaveIntervalRef = useRef<number | null>(null);
const lastInputAtRef = useRef<number | null>(null); const lastInputAtRef = useRef<number | null>(null);
@@ -192,6 +207,74 @@ const CreatePost: React.FC = () => {
} }
}; };
const insertImageMarkdown = (url: string, altText: string = 'image') => {
const markdown = `![${altText}](${url})`;
const api = editorApiRef.current;
if (api && typeof api.replaceSelection === 'function') {
api.replaceSelection(markdown);
} else {
setValue(prev => `${prev || ''}\n${markdown}`);
}
};
const handleLocalUpload = () => {
setShowImageDialog(false);
fileInputRef.current?.click();
};
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const url = await uploadImage(file);
insertImageMarkdown(url, file.name || 'image');
} catch (error: any) {
const msg = String(error?.message || '');
if (msg.includes('UPLOAD_TOO_LARGE')) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
} else {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
}
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleUrlConfirm = () => {
const url = imageUrl.trim();
if (!url) {
dispatchToast(
<Toast>
<ToastTitle>URL</ToastTitle>
</Toast>,
{ intent: 'error' }
);
return;
}
insertImageMarkdown(url, 'image');
setImageUrl('');
setShowUrlDialog(false);
};
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (
@@ -295,8 +378,31 @@ const CreatePost: React.FC = () => {
}; };
}, []); }, []);
const commands = React.useMemo(() => {
const base = getCommands();
return base.map((cmd: any) => {
if (cmd?.name === 'image' || cmd?.keyCommand === 'image') {
return {
...cmd,
execute: (_state: any, api: any) => {
editorApiRef.current = api;
setShowImageDialog(true);
}
};
}
return cmd;
});
}, []);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div className={styles.editorWrapper} data-color-mode={isDarkMode ? "dark" : "light"}> <div className={styles.editorWrapper} data-color-mode={isDarkMode ? "dark" : "light"}>
<MDEditor <MDEditor
value={value} value={value}
@@ -306,7 +412,7 @@ const CreatePost: React.FC = () => {
textareaProps={{ textareaProps={{
placeholder: "请在此输入投稿内容...", placeholder: "请在此输入投稿内容...",
}} }}
commands={getCommands()} commands={commands}
extraCommands={getExtraCommands()} extraCommands={getExtraCommands()}
previewOptions={{ previewOptions={{
remarkPlugins: [remarkTagPlugin], remarkPlugins: [remarkTagPlugin],
@@ -377,6 +483,41 @@ const CreatePost: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
<Dialog open={showImageDialog} onOpenChange={(_, data) => setShowImageDialog(!!data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle></DialogTitle>
<DialogContent></DialogContent>
<DialogActions>
<div className={styles.imageActions}>
<Button appearance="secondary" onClick={() => {}}></Button>
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowUrlDialog(true); }}>URL</Button>
<Button appearance="primary" onClick={handleLocalUpload}></Button>
</div>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<Dialog open={showUrlDialog} onOpenChange={(_, data) => setShowUrlDialog(!!data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>URL</DialogTitle>
<DialogContent>
<Input
value={imageUrl}
onChange={(_, d) => setImageUrl(d.value)}
placeholder="https://example.com/image.png"
/>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setShowUrlDialog(false)}></Button>
<Button appearance="primary" onClick={handleUrlConfirm}></Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { makeStyles, tokens, Button, shorthands, Text } from '@fluentui/react-components';
import { ArrowDownload24Regular, Dismiss24Regular } from '@fluentui/react-icons';
import { getFileName } from '../api';
const useStyles = makeStyles({
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(2px)',
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
viewerContainer: {
position: 'relative',
maxWidth: '90vw',
maxHeight: '85vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
boxShadow: 'none',
...shorthands.borderRadius(tokens.borderRadiusLarge),
},
topRightControls: {
position: 'fixed',
top: tokens.spacingVerticalM,
right: tokens.spacingHorizontalM,
display: 'flex',
gap: tokens.spacingHorizontalS,
},
image: {
maxWidth: '90vw',
maxHeight: '80vh',
userSelect: 'none',
cursor: 'grab',
display: 'block',
...shorthands.borderRadius(tokens.borderRadiusMedium),
boxShadow: tokens.shadow8,
},
filename: {
position: 'fixed',
bottom: tokens.spacingVerticalS,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.4)',
color: '#fff',
...shorthands.padding(tokens.spacingVerticalXS, tokens.spacingHorizontalS),
...shorthands.borderRadius(tokens.borderRadiusSmall),
},
});
export interface ImageViewerProps {
src: string;
alt?: string;
onClose: () => void;
}
const clamp = (val: number, min: number, max: number) => Math.min(max, Math.max(min, val));
const extractPath = (src: string): string | null => {
try {
const url = new URL(src, window.location.href);
const parts = url.pathname.split('/').filter(Boolean);
return parts.pop() || null;
} catch {
const parts = src.split('?')[0].split('/').filter(Boolean);
return parts.pop() || null;
}
};
const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
const styles = useStyles();
const [scale, setScale] = React.useState(1);
const [dragging, setDragging] = React.useState(false);
const [offset, setOffset] = React.useState({ x: 0, y: 0 });
const [filename, setFilename] = React.useState<string>('image');
const startRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const offsetRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
React.useEffect(() => {
setScale(1);
setOffset({ x: 0, y: 0 });
offsetRef.current = { x: 0, y: 0 };
}, [src]);
React.useEffect(() => {
const path = extractPath(src);
const fallback = path || 'image';
setFilename(fallback);
if (!path) return;
getFileName(path)
.then((name) => setFilename(name || fallback))
.catch(() => setFilename(fallback));
}, [src]);
const handleWheel: React.WheelEventHandler<HTMLImageElement> = (e) => {
e.preventDefault();
const next = clamp(scale + (e.deltaY < 0 ? 0.15 : -0.15), 0.5, 5);
setScale(next);
};
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = (e) => {
e.preventDefault();
setDragging(true);
startRef.current = { x: e.clientX, y: e.clientY };
offsetRef.current = { ...offset };
};
const handleMouseMove: React.MouseEventHandler<HTMLImageElement> = (e) => {
if (!dragging) return;
const dx = e.clientX - startRef.current.x;
const dy = e.clientY - startRef.current.y;
setOffset({ x: offsetRef.current.x + dx, y: offsetRef.current.y + dy });
};
const handleMouseUpOrLeave: React.MouseEventHandler<HTMLImageElement> = () => {
setDragging(false);
};
const handleDoubleClick: React.MouseEventHandler<HTMLImageElement> = () => {
setScale((s) => (s > 1 ? 1 : 2));
setOffset({ x: 0, y: 0 });
};
const handleDownload = async () => {
try {
const res = await fetch(src, { mode: 'cors' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename || 'image';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
} catch {
const a = document.createElement('a');
a.href = src;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
};
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.topRightControls}>
<Button appearance="subtle" icon={<ArrowDownload24Regular />} aria-label="下载图片" onClick={handleDownload} />
<Button appearance="subtle" icon={<Dismiss24Regular />} aria-label="关闭查看器" onClick={onClose} />
</div>
<div className={styles.viewerContainer} onClick={(e) => e.stopPropagation()}>
<img
src={src}
alt={alt || 'image'}
className={styles.image}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUpOrLeave}
onMouseLeave={handleMouseUpOrLeave}
onDoubleClick={handleDoubleClick}
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }}
/>
</div>
<Text size={100} className={styles.filename}>{filename}</Text>
</div>
);
};
export default ImageViewer;

View File

@@ -1,4 +1,5 @@
import { makeStyles, tokens, FluentProvider, webLightTheme, webDarkTheme, Toaster, useId } from '@fluentui/react-components'; import { makeStyles, tokens, FluentProvider, webLightTheme, webDarkTheme, Toaster, useId } from '@fluentui/react-components';
import type { ReactNode } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import Header from './components/Header'; import Header from './components/Header';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
@@ -51,7 +52,7 @@ const useStyles = makeStyles({
} }
}); });
const LayoutContent = ({ toasterId }: { toasterId: string }) => { const LayoutContent = ({ toasterId, imageViewer }: { toasterId: string; imageViewer?: ReactNode }) => {
const styles = useStyles(); const styles = useStyles();
const { isDarkMode } = useLayout(); const { isDarkMode } = useLayout();
@@ -70,16 +71,17 @@ const LayoutContent = ({ toasterId }: { toasterId: string }) => {
</div> </div>
<Footer /> <Footer />
<Toaster toasterId={toasterId} position="top-end" /> <Toaster toasterId={toasterId} position="top-end" />
{imageViewer}
</div> </div>
</FluentProvider> </FluentProvider>
); );
}; };
export const MainLayout = () => { export const MainLayout = ({ imageViewer }: { imageViewer?: ReactNode }) => {
const toasterId = useId('toaster'); const toasterId = useId('toaster');
return ( return (
<LayoutProvider toasterId={toasterId}> <LayoutProvider toasterId={toasterId}>
<LayoutContent toasterId={toasterId} /> <LayoutContent toasterId={toasterId} imageViewer={imageViewer} />
</LayoutProvider> </LayoutProvider>
); );
}; };

View File

@@ -62,13 +62,13 @@ const Header = () => {
appearance="transparent" appearance="transparent"
icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />} icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />}
onClick={toggleTheme} onClick={toggleTheme}
title="Toggle Theme" title="切换主题"
/> />
{settings?.enableCodeIcon && ( {settings?.enableCodeIcon && (
<Button <Button
appearance="transparent" appearance="transparent"
icon={<Code24Regular />} icon={<Code24Regular />}
title="Source Code" title="项目源代码"
onClick={() => window.open(settings.repoUrl, '_blank', 'noopener,noreferrer')} onClick={() => window.open(settings.repoUrl, '_blank', 'noopener,noreferrer')}
/> />
)} )}