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的后端代码喵!
|
# 这里是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:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
| 2003 | 失败。服务器内部错误。 |
|
| 2003 | 失败。服务器内部错误。 |
|
||||||
| 2004 | 失败。试图使用不存在的Identity。 |
|
| 2004 | 失败。试图使用不存在的Identity。 |
|
||||||
| 2005 | 失败。提交内容包含违禁词。 |
|
| 2005 | 失败。提交内容包含违禁词。 |
|
||||||
|
| 2006 | 失败。上传的图片超出限制大小。 |
|
||||||
|
| 2007 | 失败。上传的文件类型不支持。 |
|
||||||
| 404 | api端点不存在。 |
|
| 404 | api端点不存在。 |
|
||||||
| | |
|
| | |
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 = ``;
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user