diff --git a/back/main.py b/back/main.py index 0452797..c68d592 100644 --- a/back/main.py +++ b/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/', 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: diff --git a/code_meaning.md b/code_meaning.md index 2ca3030..76a302a 100644 --- a/code_meaning.md +++ b/code_meaning.md @@ -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端点不存在。 | | | | | | | diff --git a/dev_proxy.py b/dev_proxy.py index 41dc6b3..e418708 100644 --- a/dev_proxy.py +++ b/dev_proxy.py @@ -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) diff --git a/front/src/App.tsx b/front/src/App.tsx index b007f65..031d624 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -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} /> ); @@ -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 = () =>

404 Not Found

; 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 ( - }> - } /> + + ) : null + } + /> + } + > + } /> } /> } /> } /> diff --git a/front/src/api.ts b/front/src/api.ts index 58f6eaa..3aacf0a 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -290,3 +290,52 @@ export const postComment = async (commentData: PostCommentRequest): Promise => { + 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 => { + 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; + } +}; diff --git a/front/src/components/CreatePost.tsx b/front/src/components/CreatePost.tsx index 9b910ff..2426125 100644 --- a/front/src/components/CreatePost.tsx +++ b/front/src/components/CreatePost.tsx @@ -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(null); const tagButtonRef = useRef(null); + const fileInputRef = useRef(null); + const editorApiRef = useRef(null); const valueRef = useRef(""); const autoSaveIntervalRef = useRef(null); const lastInputAtRef = useRef(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 = 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( + + 上传的图片超出限制大小 + , + { intent: 'error' } + ); + } else if (msg.includes('UNSUPPORTED_FORMAT')) { + dispatchToast( + + 上传的文件类型不支持 + , + { intent: 'error' } + ); + } else { + dispatchToast( + + 图片上传失败 + , + { intent: 'error' } + ); + } + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleUrlConfirm = () => { + const url = imageUrl.trim(); + if (!url) { + dispatchToast( + + 请输入图片URL + , + { 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 (
+
{ textareaProps={{ placeholder: "请在此输入投稿内容...", }} - commands={getCommands()} + commands={commands} extraCommands={getExtraCommands()} previewOptions={{ remarkPlugins: [remarkTagPlugin], @@ -377,6 +483,41 @@ const CreatePost: React.FC = () => {
+ + setShowImageDialog(!!data.open)}> + + + 插入图片 + 请选择图片来源 + +
+ + + +
+
+
+
+
+ + setShowUrlDialog(!!data.open)}> + + + 输入图片URL + + setImageUrl(d.value)} + placeholder="https://example.com/image.png" + /> + + + + + + + + ); }; diff --git a/front/src/components/ImageViewer.tsx b/front/src/components/ImageViewer.tsx new file mode 100644 index 0000000..4d50800 --- /dev/null +++ b/front/src/components/ImageViewer.tsx @@ -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 = ({ 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('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 = (e) => { + e.preventDefault(); + const next = clamp(scale + (e.deltaY < 0 ? 0.15 : -0.15), 0.5, 5); + setScale(next); + }; + + const handleMouseDown: React.MouseEventHandler = (e) => { + e.preventDefault(); + setDragging(true); + startRef.current = { x: e.clientX, y: e.clientY }; + offsetRef.current = { ...offset }; + }; + + const handleMouseMove: React.MouseEventHandler = (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 = () => { + setDragging(false); + }; + + const handleDoubleClick: React.MouseEventHandler = () => { + 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 ( +
+
+
+
e.stopPropagation()}> + {alt +
+ {filename} +
+ ); +}; + +export default ImageViewer; diff --git a/front/src/layouts/MainLayout.tsx b/front/src/layouts/MainLayout.tsx index 79d2fa0..5dd7aa8 100644 --- a/front/src/layouts/MainLayout.tsx +++ b/front/src/layouts/MainLayout.tsx @@ -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 }) => {