diff --git a/package.json b/package.json index c0740f6..1d24a3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sycamore_whisper_front", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/public/about.md b/public/about.md index b1c81b1..9216127 100644 --- a/public/about.md +++ b/public/about.md @@ -1,5 +1,5 @@ # Hi~欢迎来到Sycamore_Whisper匿名投稿站! -这是一个实例关于页面。 +这是一个站点关于页面。 diff --git a/src/App.tsx b/src/App.tsx index ad91581..b51268c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,5 @@ +// 你好。 + import React, { useState, useEffect, useRef, useCallback } from 'react'; import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; diff --git a/src/admin_api.tsx b/src/admin_api.tsx index fa69ef0..aaac5c6 100644 --- a/src/admin_api.tsx +++ b/src/admin_api.tsx @@ -7,28 +7,68 @@ export interface AdminAuthResponse { message?: string; } -// 管理员密码缓存键 +// 管理员令牌存储键(Cookie) const ADMIN_TOKEN_KEY = 'admin_token'; +// Cookie 工具函数 +const isHttps = () => typeof window !== 'undefined' && window.location?.protocol === 'https:'; + +const setCookie = (name: string, value: string, options?: { maxAgeSeconds?: number; path?: string; sameSite?: 'Lax' | 'Strict' | 'None' }): void => { + const path = options?.path ?? '/'; + const sameSite = options?.sameSite ?? 'Lax'; + const parts = [ + `${encodeURIComponent(name)}=${encodeURIComponent(value)}`, + `Path=${path}`, + `SameSite=${sameSite}`, + ]; + if (isHttps()) parts.push('Secure'); + if (typeof options?.maxAgeSeconds === 'number') parts.push(`Max-Age=${options!.maxAgeSeconds}`); + document.cookie = parts.join('; '); +}; + +const getCookie = (name: string): string | null => { + const target = encodeURIComponent(name) + '='; + const cookies = document.cookie ? document.cookie.split('; ') : []; + for (const c of cookies) { + if (c.startsWith(target)) { + try { return decodeURIComponent(c.slice(target.length)); } catch { return c.slice(target.length); } + } + } + return null; +}; + +const deleteCookie = (name: string): void => { + const parts = [ + `${encodeURIComponent(name)}=`, + 'Path=/', + 'Max-Age=0', + 'SameSite=Lax', + ]; + if (isHttps()) parts.push('Secure'); + document.cookie = parts.join('; '); +}; + /** * 获取存储的管理员令牌 */ export const getAdminToken = (): string | null => { - return localStorage.getItem(ADMIN_TOKEN_KEY); + return getCookie(ADMIN_TOKEN_KEY); }; /** * 存储管理员令牌 */ -export const setAdminToken = (token: string): void => { - localStorage.setItem(ADMIN_TOKEN_KEY, token); +export const setAdminToken = (token: string, persist: boolean = false): void => { + // persist=false: 会话 Cookie(浏览器关闭后失效);persist=true: 持久 Cookie(默认 7 天) + const maxAge = persist ? 7 * 24 * 60 * 60 : undefined; + setCookie(ADMIN_TOKEN_KEY, token, { maxAgeSeconds: maxAge, path: '/', sameSite: 'Lax' }); }; /** * 清除管理员令牌 */ export const clearAdminToken = (): void => { - localStorage.removeItem(ADMIN_TOKEN_KEY); + deleteCookie(ADMIN_TOKEN_KEY); }; /** @@ -36,7 +76,7 @@ export const clearAdminToken = (): void => { * @param password 管理员密码 * @returns Promise */ -export const verifyAdminPassword = async (password: string): Promise => { +export const verifyAdminPassword = async (password: string, remember: boolean = false): Promise => { try { const response = await fetch(`${API_CONFIG.BASE_URL}/admin/test`, { method: 'GET', @@ -54,8 +94,8 @@ export const verifyAdminPassword = async (password: string): Promise = ({ const [bannedSaving, setBannedSaving] = React.useState(false); const fileImportRef = React.useRef(null); const [importing, setImporting] = React.useState(false); + // 图片预览器状态 + const [imageViewer, setImageViewer] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false }); React.useEffect(() => { if (activeTab === 'systemSettings') { @@ -923,7 +928,7 @@ const AdminDashboard: React.FC = ({ {picList.map((item, idx) => (
{item.url && item.url.trim() !== '' ? ( - {item.filename + {item.filename setImageViewer({ open: true, src: item.url, alt: item.filename || '图片' })} /> ) : (
无图片链接 @@ -962,6 +967,9 @@ const AdminDashboard: React.FC = ({ + {imageViewer.open && imageViewer.src ? ( + setImageViewer({ open: false })} /> + ) : null}
) : activeTab === 'complaintReview' ? (
@@ -1049,11 +1057,11 @@ const AdminDashboard: React.FC = ({
- {/* 页脚 - 采用 MainLayout 的 Footer 样式 */} + {/* 页脚 */}
- - Powered By Sycamore_Whisper - +
+ {SITE_FOOTER_MD} +
); diff --git a/src/components/AdminLogin.tsx b/src/components/AdminLogin.tsx index 462b373..e2dccdc 100644 --- a/src/components/AdminLogin.tsx +++ b/src/components/AdminLogin.tsx @@ -10,8 +10,10 @@ import { tokens, Spinner, Field, + Checkbox, } from '@fluentui/react-components'; -import { LockClosed24Regular, Shield24Regular, ShieldLock24Regular} from '@fluentui/react-icons'; +import { LockClosed24Regular, Shield24Regular, ShieldLock24Regular, ArrowLeft24Regular } from '@fluentui/react-icons'; +import { Link } from 'react-router-dom'; import { verifyAdminPassword } from '../admin_api'; import { toast } from 'react-hot-toast'; @@ -73,6 +75,11 @@ const useStyles = makeStyles({ width: '100%', marginTop: tokens.spacingVerticalS, }, + backHomeContainer: { + display: 'flex', + justifyContent: 'center', + marginTop: tokens.spacingVerticalS, + }, loadingContainer: { display: 'flex', alignItems: 'center', @@ -89,6 +96,7 @@ const AdminLogin: React.FC = ({ onLoginSuccess }) => { const styles = useStyles(); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + const [remember, setRemember] = useState(false); const handleLogin = async () => { if (!password.trim()) { @@ -98,7 +106,7 @@ const AdminLogin: React.FC = ({ onLoginSuccess }) => { setLoading(true); try { - const result = await verifyAdminPassword(password); + const result = await verifyAdminPassword(password, remember); if (result.success) { toast.success(result.message || '登录成功'); @@ -153,6 +161,13 @@ const AdminLogin: React.FC = ({ onLoginSuccess }) => { /> + setRemember(!!data.checked)} + label="记住密码" + disabled={loading} + /> + + + + +
+ + + +
diff --git a/src/components/AdminPostCard.tsx b/src/components/AdminPostCard.tsx index ace17ee..a5cb8ce 100644 --- a/src/components/AdminPostCard.tsx +++ b/src/components/AdminPostCard.tsx @@ -18,6 +18,7 @@ import { Comment24Regular, Delete24Regular, } from '@fluentui/react-icons'; +import ImageViewer from './ImageViewer'; const useStyles = makeStyles({ card: { @@ -92,6 +93,13 @@ const useStyles = makeStyles({ textDecoration: 'underline', backgroundColor: 'transparent', }, + // 约束 Markdown 图片不溢出卡片 + '& img': { + maxWidth: '100%', + height: 'auto', + display: 'block', + borderRadius: tokens.borderRadiusSmall, + }, }, actions: { display: 'grid', @@ -137,15 +145,35 @@ const AdminPostCard: React.FC = ({ }) => { const styles = useStyles(); const markdownContent = content; + const [imageViewer, setImageViewer] = React.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 ( + <>
帖子 #{id}
- {markdownContent} + ( + openImageViewer(props.src as string, props.alt as string)} + /> + ), + }} + > + {markdownContent} +
@@ -159,6 +187,10 @@ const AdminPostCard: React.FC = ({
+ {imageViewer.open && imageViewer.src && ( + + )} + ); }; diff --git a/src/components/CreatePost.tsx b/src/components/CreatePost.tsx index 9bf8340..980f589 100644 --- a/src/components/CreatePost.tsx +++ b/src/components/CreatePost.tsx @@ -53,10 +53,69 @@ const CreatePost: React.FC = ({ onSubmitSuccess }) => { }; const handleImageUpload = async (file: File): Promise => { + // 轻度压缩:最大宽高 1920,JPEG/WebP 质量 0.85; + // 对 PNG/GIF 保留格式,仅缩放尺寸以避免质量损失或动画丢失。 + const compressImage = (srcFile: File): Promise => { + return new Promise((resolve) => { + try { + const isGif = srcFile.type === 'image/gif'; + // 对动图或极小文件不做压缩 + if (isGif || srcFile.size < 150 * 1024) { + resolve(srcFile); + return; + } + + const img = new Image(); + img.onload = () => { + const maxW = 1920; + const maxH = 1920; + let { width, height } = img; + const ratio = Math.min(1, Math.min(maxW / width, maxH / height)); + const targetW = Math.max(1, Math.round(width * ratio)); + const targetH = Math.max(1, Math.round(height * ratio)); + + const canvas = document.createElement('canvas'); + canvas.width = targetW; + canvas.height = targetH; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(srcFile); + return; + } + ctx.drawImage(img, 0, 0, targetW, targetH); + + const preferJpeg = srcFile.type === 'image/jpeg' || srcFile.type === 'image/jpg'; + const preferPng = srcFile.type === 'image/png'; + const mime = preferJpeg ? 'image/jpeg' : preferPng ? 'image/png' : 'image/webp'; + const quality = preferPng ? undefined : 0.85; // PNG 质量参数无效,仅靠缩放降体积 + + canvas.toBlob( + (blob) => { + if (!blob) { + resolve(srcFile); + return; + } + const outName = srcFile.name; + const fileOut = new File([blob], outName, { type: mime }); + // 若压缩后反而更大,则沿用原文件 + resolve(fileOut.size < srcFile.size ? fileOut : srcFile); + }, + mime, + quality + ); + }; + img.onerror = () => resolve(srcFile); + img.src = URL.createObjectURL(srcFile); + } catch { + resolve(srcFile); + } + }); + }; try { + const compressed = await compressImage(file); const formData = new FormData(); - formData.append('file', file); + formData.append('file', compressed); const response = await uploadImage(formData); if (response.status === 'OK' && response.url) { diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx new file mode 100644 index 0000000..e19e48d --- /dev/null +++ b/src/components/ImageViewer.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { makeStyles, tokens, Button, shorthands, Text } from '@fluentui/react-components'; +import { ArrowDownload24Regular, Dismiss24Regular } from '@fluentui/react-icons'; + +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 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 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]); + + const filename = React.useMemo(() => { + try { + const url = new URL(src, window.location.href); + const pathname = url.pathname || ''; + const name = pathname.split('/').filter(Boolean).pop(); + return name || 'image'; + } catch { + const parts = src.split('?')[0].split('/'); + return parts.pop() || 'image'; + } + }, [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 () => { + // 优先通过 fetch 获取 Blob,再使用 Object URL 触发浏览器下载; + // 这样可以规避跨域 URL 忽略 download 属性导致的直接跳转问题。 + try { + const res = await fetch(src, { mode: 'cors' }); + // 某些站点可能返回非 2xx,但仍有文件内容;此处仅在严格失败时降级 + 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; + document.body.appendChild(a); + a.click(); + a.remove(); + // 延迟释放以避免某些浏览器在点击后立即撤销 URL 导致失败 + setTimeout(() => URL.revokeObjectURL(objectUrl), 1500); + } catch (err) { + // 降级:无法 fetch(CORS/网络)时打开新标签,让用户自行另存为 + 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; \ No newline at end of file diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index a5b1436..0b83755 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -20,6 +20,7 @@ import { } from '@fluentui/react-icons'; import ReportPost from './ReportPost'; import CommentSection from './CommentSection'; +import ImageViewer from './ImageViewer'; const useStyles = makeStyles({ card: { @@ -91,6 +92,13 @@ const useStyles = makeStyles({ textDecoration: 'underline', backgroundColor: 'transparent', }, + // 约束 Markdown 图片不溢出卡片 + '& img': { + maxWidth: '100%', + height: 'auto', + display: 'block', + borderRadius: tokens.borderRadiusSmall, + }, }, actions: { display: 'grid', @@ -146,12 +154,32 @@ const PostCard = ({ const [hasVoted, setHasVoted] = React.useState(false); const [showReportModal, setShowReportModal] = React.useState(false); const [showComments, setShowComments] = React.useState(false); + const [imageViewer, setImageViewer] = React.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 (
- {markdownContent} + ( + openImageViewer(props.src as string, props.alt as string)} + /> + ), + }} + > + {markdownContent} +
@@ -220,6 +248,10 @@ const PostCard = ({ setShowReportModal(false)} /> )} + + {imageViewer.open && imageViewer.src && ( + + )}
); }; diff --git a/src/config.ts b/src/config.ts index b82bded..3d20e25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,15 @@ // 后端API配置 export const API_CONFIG = { - BASE_URL: 'http://127.0.0.1:5000' // 此处填写API BaseURL,如果前端使用https,后端最好也使用https + BASE_URL: 'http://127.0.0.1:5000' // 此处填写API BaseURL,如果前端使用https,后端也请使用https }; export const SITE_TITLE = 'Sycamore_Whisper'; // 此处填写站点标题 +// 自定义页脚,可用于展示备案号 +export const SITE_FOOTER_MD = 'Powered By **Sycamore_Whisper**'; +// 是否开启右上角 源代码 图标按钮 +export const EnableCodeIcon = true; +// 仓库跳转链接 +export const RepoUrl = 'https://github.com/Sycamore-Whisper/frontend'; export default API_CONFIG; // 接下来,请修改默认站点图标 diff --git a/src/layouts/components/Footer.tsx b/src/layouts/components/Footer.tsx index 2aaf5b1..ecd8555 100644 --- a/src/layouts/components/Footer.tsx +++ b/src/layouts/components/Footer.tsx @@ -1,4 +1,7 @@ -import { makeStyles, Text, tokens } from '@fluentui/react-components'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { SITE_FOOTER_MD } from '../../config'; const useStyles = makeStyles({ footer: { @@ -9,6 +12,15 @@ const useStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground1, borderTop: `1px solid ${tokens.colorNeutralStroke1}`, }, + markdown: { + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + lineHeight: '20px', + textAlign: 'center', + // 约束可能的图片或表格 + '& img': { maxWidth: '100%', height: 'auto', display: 'inline-block', verticalAlign: 'middle' }, + '& a': { color: tokens.colorBrandForegroundLink }, + }, }); const Footer = () => { @@ -16,9 +28,9 @@ const Footer = () => { return (
- - Powered By Sycamore_Whisper - +
+ {SITE_FOOTER_MD} +
); }; diff --git a/src/layouts/components/Header.tsx b/src/layouts/components/Header.tsx index 5f2ce74..b7c6aa7 100644 --- a/src/layouts/components/Header.tsx +++ b/src/layouts/components/Header.tsx @@ -1,8 +1,8 @@ import { makeStyles, Text, tokens, Button } from '@fluentui/react-components'; -import { WeatherSunny24Regular, WeatherMoon24Regular } from '@fluentui/react-icons'; +import { WeatherSunny24Regular, WeatherMoon24Regular, Code24Regular } from '@fluentui/react-icons'; import icon from '/icon.png'; -import { SITE_TITLE } from '../../config'; +import { SITE_TITLE, EnableCodeIcon, RepoUrl } from '../../config'; const useStyles = makeStyles({ header: { @@ -62,6 +62,14 @@ const Header = ({ isDarkMode, onToggleTheme, onToggleSidebar }: HeaderProps) => onClick={onToggleTheme} className={styles.themeToggle} /> + {EnableCodeIcon && ( +