增加图片查看器,优化体验
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sycamore_whisper_front",
|
"name": "sycamore_whisper_front",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Hi~欢迎来到Sycamore_Whisper匿名投稿站!
|
# Hi~欢迎来到Sycamore_Whisper匿名投稿站!
|
||||||
这是一个实例关于页面。
|
这是一个站点关于页面。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// 你好。
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components';
|
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
|||||||
@@ -7,28 +7,68 @@ export interface AdminAuthResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员密码缓存键
|
// 管理员令牌存储键(Cookie)
|
||||||
const ADMIN_TOKEN_KEY = 'admin_token';
|
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 => {
|
export const getAdminToken = (): string | null => {
|
||||||
return localStorage.getItem(ADMIN_TOKEN_KEY);
|
return getCookie(ADMIN_TOKEN_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储管理员令牌
|
* 存储管理员令牌
|
||||||
*/
|
*/
|
||||||
export const setAdminToken = (token: string): void => {
|
export const setAdminToken = (token: string, persist: boolean = false): void => {
|
||||||
localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
// 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 => {
|
export const clearAdminToken = (): void => {
|
||||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
deleteCookie(ADMIN_TOKEN_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +76,7 @@ export const clearAdminToken = (): void => {
|
|||||||
* @param password 管理员密码
|
* @param password 管理员密码
|
||||||
* @returns Promise<AdminAuthResponse>
|
* @returns Promise<AdminAuthResponse>
|
||||||
*/
|
*/
|
||||||
export const verifyAdminPassword = async (password: string): Promise<AdminAuthResponse> => {
|
export const verifyAdminPassword = async (password: string, remember: boolean = false): Promise<AdminAuthResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/admin/test`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/admin/test`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -54,8 +94,8 @@ export const verifyAdminPassword = async (password: string): Promise<AdminAuthRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// 密码正确,存储到缓存
|
// 密码正确,存储到 Cookie
|
||||||
setAdminToken(password);
|
setAdminToken(password, !!remember);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '登录成功'
|
message: '登录成功'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
shorthands,
|
shorthands,
|
||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import type { TabValue } from '@fluentui/react-components';
|
import type { TabValue } from '@fluentui/react-components';
|
||||||
import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost, modifyPost,
|
import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost,
|
||||||
getBannedKeywords, setBannedKeywordsList } from '../admin_api';
|
getBannedKeywords, setBannedKeywordsList } from '../admin_api';
|
||||||
import { Switch } from '@fluentui/react-components';
|
import { Switch } from '@fluentui/react-components';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -31,13 +31,16 @@ import {
|
|||||||
QuestionCircle20Regular,
|
QuestionCircle20Regular,
|
||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import { adminLogout } from '../admin_api';
|
import { adminLogout } from '../admin_api';
|
||||||
import { SITE_TITLE } from '../config';
|
import { SITE_TITLE, SITE_FOOTER_MD } from '../config';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
import icon from '/icon.png';
|
import icon from '/icon.png';
|
||||||
import AdminPostCard from './AdminPostCard';
|
import AdminPostCard from './AdminPostCard';
|
||||||
import AdminModifyPost from './AdminModifyPost';
|
import AdminModifyPost from './AdminModifyPost';
|
||||||
import AdminManageComments from './AdminManageComments';
|
import AdminManageComments from './AdminManageComments';
|
||||||
import { fetchArticles, type Article } from '../api';
|
import { fetchArticles, type Article } from '../api';
|
||||||
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -212,6 +215,8 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
const [bannedSaving, setBannedSaving] = React.useState<boolean>(false);
|
const [bannedSaving, setBannedSaving] = React.useState<boolean>(false);
|
||||||
const fileImportRef = React.useRef<HTMLInputElement | null>(null);
|
const fileImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [importing, setImporting] = React.useState<boolean>(false);
|
const [importing, setImporting] = React.useState<boolean>(false);
|
||||||
|
// 图片预览器状态
|
||||||
|
const [imageViewer, setImageViewer] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab === 'systemSettings') {
|
if (activeTab === 'systemSettings') {
|
||||||
@@ -923,7 +928,7 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
{picList.map((item, idx) => (
|
{picList.map((item, idx) => (
|
||||||
<div key={`${picPage}-${item.filename || item.url || 'unknown'}-${item.upload_time || 'na'}-${idx}`} style={{ border: `1px solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusMedium, padding: tokens.spacingHorizontalS }}>
|
<div key={`${picPage}-${item.filename || item.url || 'unknown'}-${item.upload_time || 'na'}-${idx}`} style={{ border: `1px solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusMedium, padding: tokens.spacingHorizontalS }}>
|
||||||
{item.url && item.url.trim() !== '' ? (
|
{item.url && item.url.trim() !== '' ? (
|
||||||
<img src={item.url} alt={item.filename || '图片'} style={{ width: '100%', height: '140px', objectFit: 'cover', borderRadius: tokens.borderRadiusSmall }} />
|
<img src={item.url} alt={item.filename || '图片'} style={{ width: '100%', height: '140px', objectFit: 'cover', borderRadius: tokens.borderRadiusSmall, cursor: 'zoom-in' }} onClick={() => setImageViewer({ open: true, src: item.url, alt: item.filename || '图片' })} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: '100%', height: '140px', borderRadius: tokens.borderRadiusSmall, backgroundColor: tokens.colorNeutralBackground3, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: '100%', height: '140px', borderRadius: tokens.borderRadiusSmall, backgroundColor: tokens.colorNeutralBackground3, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Text size={200} color="subtle">无图片链接</Text>
|
<Text size={200} color="subtle">无图片链接</Text>
|
||||||
@@ -962,6 +967,9 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{imageViewer.open && imageViewer.src ? (
|
||||||
|
<ImageViewer src={imageViewer.src} alt={imageViewer.alt} onClose={() => setImageViewer({ open: false })} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'complaintReview' ? (
|
) : activeTab === 'complaintReview' ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -1049,11 +1057,11 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 页脚 - 采用 MainLayout 的 Footer 样式 */}
|
{/* 页脚 */}
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Text size={200} color="subtle">
|
<div style={{ color: tokens.colorNeutralForeground2, fontSize: tokens.fontSizeBase200, lineHeight: '20px', textAlign: 'center' }}>
|
||||||
Powered By Sycamore_Whisper
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{SITE_FOOTER_MD}</ReactMarkdown>
|
||||||
</Text>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
tokens,
|
tokens,
|
||||||
Spinner,
|
Spinner,
|
||||||
Field,
|
Field,
|
||||||
|
Checkbox,
|
||||||
} from '@fluentui/react-components';
|
} 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 { verifyAdminPassword } from '../admin_api';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -73,6 +75,11 @@ const useStyles = makeStyles({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: tokens.spacingVerticalS,
|
marginTop: tokens.spacingVerticalS,
|
||||||
},
|
},
|
||||||
|
backHomeContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: tokens.spacingVerticalS,
|
||||||
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -89,6 +96,7 @@ const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
@@ -98,7 +106,7 @@ const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await verifyAdminPassword(password);
|
const result = await verifyAdminPassword(password, remember);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || '登录成功');
|
toast.success(result.message || '登录成功');
|
||||||
@@ -153,6 +161,13 @@ const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={remember}
|
||||||
|
onChange={(_, data) => setRemember(!!data.checked)}
|
||||||
|
label="记住密码"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -169,6 +184,14 @@ const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
|
|||||||
'登录'
|
'登录'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className={styles.backHomeContainer}>
|
||||||
|
<Link to="/" style={{ textDecoration: 'none' }}>
|
||||||
|
<Button appearance="transparent" icon={<ArrowLeft24Regular />}>返回首页</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardPreview>
|
</CardPreview>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Comment24Regular,
|
Comment24Regular,
|
||||||
Delete24Regular,
|
Delete24Regular,
|
||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
card: {
|
card: {
|
||||||
@@ -92,6 +93,13 @@ const useStyles = makeStyles({
|
|||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
|
// 约束 Markdown 图片不溢出卡片
|
||||||
|
'& img': {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -137,15 +145,35 @@ const AdminPostCard: React.FC<AdminPostCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const markdownContent = content;
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<Text size={300} weight="semibold">帖子 #{id}</Text>
|
<Text size={300} weight="semibold">帖子 #{id}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div style={{ whiteSpace: 'pre-wrap' }}>
|
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{markdownContent}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkIns]}
|
||||||
|
components={{
|
||||||
|
img: (props) => (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
style={{ cursor: 'zoom-in', maxWidth: '100%', height: 'auto', display: 'block' }}
|
||||||
|
onClick={() => openImageViewer(props.src as string, props.alt as string)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdownContent}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
@@ -159,6 +187,10 @@ const AdminPostCard: React.FC<AdminPostCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
{imageViewer.open && imageViewer.src && (
|
||||||
|
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,69 @@ const CreatePost: React.FC<CreatePostProps> = ({ onSubmitSuccess }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (file: File): Promise<string> => {
|
const handleImageUpload = async (file: File): Promise<string> => {
|
||||||
|
// 轻度压缩:最大宽高 1920,JPEG/WebP 质量 0.85;
|
||||||
|
// 对 PNG/GIF 保留格式,仅缩放尺寸以避免质量损失或动画丢失。
|
||||||
|
const compressImage = (srcFile: File): Promise<File> => {
|
||||||
|
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 {
|
try {
|
||||||
|
const compressed = await compressImage(file);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', compressed);
|
||||||
|
|
||||||
const response = await uploadImage(formData);
|
const response = await uploadImage(formData);
|
||||||
if (response.status === 'OK' && response.url) {
|
if (response.status === 'OK' && response.url) {
|
||||||
|
|||||||
175
src/components/ImageViewer.tsx
Normal file
175
src/components/ImageViewer.tsx
Normal file
@@ -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<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 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<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 () => {
|
||||||
|
// 优先通过 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 (
|
||||||
|
<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;
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import ReportPost from './ReportPost';
|
import ReportPost from './ReportPost';
|
||||||
import CommentSection from './CommentSection';
|
import CommentSection from './CommentSection';
|
||||||
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
card: {
|
card: {
|
||||||
@@ -91,6 +92,13 @@ const useStyles = makeStyles({
|
|||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
|
// 约束 Markdown 图片不溢出卡片
|
||||||
|
'& img': {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -146,12 +154,32 @@ const PostCard = ({
|
|||||||
const [hasVoted, setHasVoted] = React.useState(false);
|
const [hasVoted, setHasVoted] = React.useState(false);
|
||||||
const [showReportModal, setShowReportModal] = React.useState(false);
|
const [showReportModal, setShowReportModal] = React.useState(false);
|
||||||
const [showComments, setShowComments] = 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 (
|
return (
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div style={{ whiteSpace: 'pre-wrap' }}>
|
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{markdownContent}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkIns]}
|
||||||
|
components={{
|
||||||
|
img: (props) => (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
style={{ cursor: 'zoom-in', maxWidth: '100%', height: 'auto', display: 'block' }}
|
||||||
|
onClick={() => openImageViewer(props.src as string, props.alt as string)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdownContent}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
@@ -220,6 +248,10 @@ const PostCard = ({
|
|||||||
<ReportPost postId={id} onClose={() => setShowReportModal(false)} />
|
<ReportPost postId={id} onClose={() => setShowReportModal(false)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{imageViewer.open && imageViewer.src && (
|
||||||
|
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// 后端API配置
|
// 后端API配置
|
||||||
export const API_CONFIG = {
|
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_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;
|
export default API_CONFIG;
|
||||||
|
|
||||||
// 接下来,请修改默认站点图标
|
// 接下来,请修改默认站点图标
|
||||||
|
|||||||
@@ -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({
|
const useStyles = makeStyles({
|
||||||
footer: {
|
footer: {
|
||||||
@@ -9,6 +12,15 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
|
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 = () => {
|
const Footer = () => {
|
||||||
@@ -16,9 +28,9 @@ const Footer = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Text size={200} color="subtle">
|
<div className={styles.markdown}>
|
||||||
Powered By Sycamore_Whisper
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{SITE_FOOTER_MD}</ReactMarkdown>
|
||||||
</Text>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { makeStyles, Text, tokens, Button } from '@fluentui/react-components';
|
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 icon from '/icon.png';
|
||||||
import { SITE_TITLE } from '../../config';
|
import { SITE_TITLE, EnableCodeIcon, RepoUrl } from '../../config';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
header: {
|
header: {
|
||||||
@@ -62,6 +62,14 @@ const Header = ({ isDarkMode, onToggleTheme, onToggleSidebar }: HeaderProps) =>
|
|||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
/>
|
/>
|
||||||
|
{EnableCodeIcon && (
|
||||||
|
<Button
|
||||||
|
appearance="transparent"
|
||||||
|
icon={<Code24Regular />}
|
||||||
|
title="项目源代码"
|
||||||
|
onClick={() => window.open(RepoUrl, '_blank', 'noopener,noreferrer')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { makeStyles, tokens } from '@fluentui/react-components';
|
import { makeStyles, tokens } from '@fluentui/react-components';
|
||||||
import { Home24Regular, Add24Regular, History24Regular, Info24Regular, DocumentSearch24Regular, PeopleSearch24Regular, ChevronDown24Regular, ChevronRight24Regular } from '@fluentui/react-icons';
|
import { Home24Regular, Add24Regular, History24Regular, Info24Regular, DocumentSearch24Regular, PeopleSearch24Regular, ChevronDown24Regular, ChevronRight24Regular, Settings24Regular } from '@fluentui/react-icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ const menuItems = [
|
|||||||
{ path: '/progress/complaint', icon: PeopleSearch24Regular, label: '投诉受理' }
|
{ path: '/progress/complaint', icon: PeopleSearch24Regular, label: '投诉受理' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{ path: '/admin', icon: Settings24Regular, label: '管理面板' },
|
||||||
{ path: '/about', icon: Info24Regular, label: '关于' },
|
{ path: '/about', icon: Info24Regular, label: '关于' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const InitPage: React.FC = () => {
|
|||||||
<CardHeader header={<Title2> 😉 初始化后端</Title2>} />
|
<CardHeader header={<Title2> 😉 初始化后端</Title2>} />
|
||||||
<CardPreview>
|
<CardPreview>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Text weight="semibold">🎊 恭喜!只差最后一步,即可开始使用! 为保证安全,后续需通过config.py修改配置</Text>
|
<Text weight="semibold">🎊 恭喜!只差最后一步,即可开始使用! 后续可通过config.py二次修改配置</Text>
|
||||||
<Field label="管理员令牌">
|
<Field label="管理员令牌">
|
||||||
<Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" />
|
<Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ const NotFound: React.FC = () => {
|
|||||||
<div className={styles.preview} />
|
<div className={styles.preview} />
|
||||||
</CardPreview>
|
</CardPreview>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Subtitle1>抱歉,你访问的页面不存在或已被移动。</Subtitle1>
|
<Subtitle1>Oh no, 页面不见了喵😭</Subtitle1>
|
||||||
<Text>请检查链接是否正确,或使用下方按钮返回继续浏览。</Text>
|
<Text>请检查链接是否正确,或使用下方按钮继续浏览。</Text>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button appearance="primary" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>
|
<Button appearance="primary" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>
|
||||||
返回上一页
|
返回上一页
|
||||||
|
|||||||
Reference in New Issue
Block a user