Implement image upload and viewer in v2
This commit is contained in:
89
back/main.py
89
back/main.py
@@ -1,6 +1,6 @@
|
||||
# 这里是Sycamore whisper的后端代码喵!
|
||||
# 但愿比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_sqlalchemy import SQLAlchemy
|
||||
import os
|
||||
@@ -14,13 +14,18 @@ CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
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_TRACK_MODIFICATIONS'] = False
|
||||
app.config['MAX_CONTENT_LENGTH'] = APP_MAX_CONTENT_LENGTH_MB * 1024 * 1024
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# 全局配置变量
|
||||
NEED_AUDIT = True
|
||||
FILE_SIZE_LIMIT_MB = 10.0
|
||||
FILE_FORMATS = ["png", "jpg", "jpeg", "gif", "webp"]
|
||||
|
||||
# --- 定义数据库结构 ---
|
||||
class SiteSettings(db.Model):
|
||||
@@ -34,6 +39,8 @@ class SiteSettings(db.Model):
|
||||
enable_notice = db.Column(db.Boolean, default=False)
|
||||
need_audit = db.Column(db.Boolean, default=True)
|
||||
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):
|
||||
__tablename__ = 'identity'
|
||||
@@ -74,21 +81,48 @@ class DenyWord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
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():
|
||||
if not os.path.exists('./data'):
|
||||
os.makedirs('./data')
|
||||
if not os.path.exists(IMG_DIR):
|
||||
os.makedirs(IMG_DIR)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
def load_config():
|
||||
global NEED_AUDIT
|
||||
global NEED_AUDIT, FILE_SIZE_LIMIT_MB, FILE_FORMATS, APP_MAX_CONTENT_LENGTH_MB
|
||||
with app.app_context():
|
||||
try:
|
||||
settings = SiteSettings.query.first()
|
||||
if settings:
|
||||
if hasattr(settings, 'need_audit') and settings.need_audit is not None:
|
||||
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:
|
||||
print(f"Warning: Failed to load settings: {e}")
|
||||
global DENY_WORDS_CACHE
|
||||
@@ -321,6 +355,57 @@ def get_comments():
|
||||
except Exception as 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'])
|
||||
def upvote():
|
||||
try:
|
||||
|
||||
@@ -30,13 +30,15 @@
|
||||
| ---- | ---------------------------------------------------- |
|
||||
| 1000 | 正常。适用于大多数成功的GET请求的返回。 |
|
||||
| 1001 | 正常。适用于大多数成功的POST请求的返回。 |
|
||||
| 1002 | 正常。提交内容需要等待审核。 |
|
||||
| 1002 | 正常。提交内容需要等待审核。 |
|
||||
| 2000 | 失败。请求方式错误,例如缺少指定参数。 |
|
||||
| 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 |
|
||||
| 2002 | 失败。数据不存在。 |
|
||||
| 2003 | 失败。服务器内部错误。 |
|
||||
| 2004 | 失败。试图使用不存在的Identity。 |
|
||||
| 2005 | 失败。提交内容包含违禁词。 |
|
||||
| 2004 | 失败。试图使用不存在的Identity。 |
|
||||
| 2005 | 失败。提交内容包含违禁词。 |
|
||||
| 2006 | 失败。上传的图片超出限制大小。 |
|
||||
| 2007 | 失败。上传的文件类型不支持。 |
|
||||
| 404 | api端点不存在。 |
|
||||
| | |
|
||||
| | |
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
VITE_ADD = "http://localhost:5173"
|
||||
PY_ADD = "http://127.0.0.1:5000"
|
||||
PORT = 8080
|
||||
MAX_BODY_MB = 50
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("dev_proxy")
|
||||
@@ -97,7 +98,7 @@ async def ws_proxy_handler(request, target_url):
|
||||
return ws_server
|
||||
|
||||
async def main():
|
||||
app = web.Application()
|
||||
app = web.Application(client_max_size=MAX_BODY_MB * 1024 * 1024)
|
||||
# 捕获所有路径
|
||||
app.router.add_route('*', '/{path:.*}', proxy_handler)
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ import { MainLayout } from './layouts/MainLayout';
|
||||
import About from './components/About';
|
||||
import CreatePost from './components/CreatePost';
|
||||
import PostCard from './components/PostCard';
|
||||
import ImageViewer from './components/ImageViewer';
|
||||
import { fetchArticles, type Article } from './api';
|
||||
import { useLayout } from './context/LayoutContext';
|
||||
import './App.css';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
|
||||
const { refreshTrigger } = useLayout();
|
||||
const { toasterId } = useLayout();
|
||||
const { dispatchToast } = useToastController(toasterId);
|
||||
@@ -113,6 +114,7 @@ const Home: React.FC = () => {
|
||||
content={article.content}
|
||||
upvotes={article.upvotes}
|
||||
downvotes={article.downvotes}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -124,6 +126,7 @@ const Home: React.FC = () => {
|
||||
content={article.content}
|
||||
upvotes={article.upvotes}
|
||||
downvotes={article.downvotes}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -145,11 +148,29 @@ const Home: React.FC = () => {
|
||||
const NotFound = () => <h1>404 Not Found</h1>;
|
||||
|
||||
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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route
|
||||
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="about" element={<About />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
|
||||
@@ -290,3 +290,52 @@ export const postComment = async (commentData: PostCommentRequest): Promise<Post
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
Input,
|
||||
useToastController,
|
||||
Toast,
|
||||
ToastTitle
|
||||
ToastTitle,
|
||||
Dialog,
|
||||
DialogSurface,
|
||||
DialogBody,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
} from '@fluentui/react-components';
|
||||
import {
|
||||
NumberSymbol24Regular,
|
||||
@@ -25,7 +31,7 @@ import {
|
||||
} from '@fluentui/react-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { saveDraft, getDraft, createPost } from '../api';
|
||||
import { saveDraft, getDraft, createPost, uploadImage } from '../api';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
@@ -82,6 +88,10 @@ const useStyles = makeStyles({
|
||||
},
|
||||
tagButtonWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
imageActions: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,9 +138,14 @@ const CreatePost: React.FC = () => {
|
||||
// 标签输入相关状态
|
||||
const [showTagInput, setShowTagInput] = useState(false);
|
||||
const [tagInputValue, setTagInputValue] = useState("");
|
||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
|
||||
const tagInputRef = useRef<HTMLDivElement>(null);
|
||||
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editorApiRef = useRef<any>(null);
|
||||
const valueRef = useRef<string>("");
|
||||
const autoSaveIntervalRef = 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 = ``;
|
||||
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(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
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 (
|
||||
<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"}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
@@ -306,7 +412,7 @@ const CreatePost: React.FC = () => {
|
||||
textareaProps={{
|
||||
placeholder: "请在此输入投稿内容...",
|
||||
}}
|
||||
commands={getCommands()}
|
||||
commands={commands}
|
||||
extraCommands={getExtraCommands()}
|
||||
previewOptions={{
|
||||
remarkPlugins: [remarkTagPlugin],
|
||||
@@ -377,6 +483,41 @@ const CreatePost: React.FC = () => {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
181
front/src/components/ImageViewer.tsx
Normal file
181
front/src/components/ImageViewer.tsx
Normal 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeStyles, tokens, FluentProvider, webLightTheme, webDarkTheme, Toaster, useId } from '@fluentui/react-components';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
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 { isDarkMode } = useLayout();
|
||||
|
||||
@@ -70,16 +71,17 @@ const LayoutContent = ({ toasterId }: { toasterId: string }) => {
|
||||
</div>
|
||||
<Footer />
|
||||
<Toaster toasterId={toasterId} position="top-end" />
|
||||
{imageViewer}
|
||||
</div>
|
||||
</FluentProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MainLayout = () => {
|
||||
export const MainLayout = ({ imageViewer }: { imageViewer?: ReactNode }) => {
|
||||
const toasterId = useId('toaster');
|
||||
return (
|
||||
<LayoutProvider toasterId={toasterId}>
|
||||
<LayoutContent toasterId={toasterId} />
|
||||
<LayoutContent toasterId={toasterId} imageViewer={imageViewer} />
|
||||
</LayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,13 +62,13 @@ const Header = () => {
|
||||
appearance="transparent"
|
||||
icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />}
|
||||
onClick={toggleTheme}
|
||||
title="Toggle Theme"
|
||||
title="切换主题"
|
||||
/>
|
||||
{settings?.enableCodeIcon && (
|
||||
<Button
|
||||
appearance="transparent"
|
||||
icon={<Code24Regular />}
|
||||
title="Source Code"
|
||||
title="项目源代码"
|
||||
onClick={() => window.open(settings.repoUrl, '_blank', 'noopener,noreferrer')}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user