增加图片查看器,优化体验

This commit is contained in:
LeonspaceX
2025-11-15 22:37:22 +08:00
parent 56506baaf8
commit 1d732cefcc
16 changed files with 431 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "sycamore_whisper_front",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
# Hi~欢迎来到Sycamore_Whisper匿名投稿站
这是一个实例关于页面。
这是一个站点关于页面。

View File

@@ -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';

View File

@@ -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<AdminAuthResponse>
*/
export const verifyAdminPassword = async (password: string): Promise<AdminAuthResponse> => {
export const verifyAdminPassword = async (password: string, remember: boolean = false): Promise<AdminAuthResponse> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/admin/test`, {
method: 'GET',
@@ -54,8 +94,8 @@ export const verifyAdminPassword = async (password: string): Promise<AdminAuthRe
}
if (response.ok) {
// 密码正确,存储到缓存
setAdminToken(password);
// 密码正确,存储到 Cookie
setAdminToken(password, !!remember);
return {
success: true,
message: '登录成功'

View File

@@ -17,7 +17,7 @@ import {
shorthands,
} 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';
import { Switch } from '@fluentui/react-components';
import { toast } from 'react-hot-toast';
@@ -31,13 +31,16 @@ import {
QuestionCircle20Regular,
} from '@fluentui/react-icons';
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 AdminPostCard from './AdminPostCard';
import AdminModifyPost from './AdminModifyPost';
import AdminManageComments from './AdminManageComments';
import { fetchArticles, type Article } from '../api';
import ImageViewer from './ImageViewer';
const useStyles = makeStyles({
root: {
@@ -212,6 +215,8 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
const [bannedSaving, setBannedSaving] = React.useState<boolean>(false);
const fileImportRef = React.useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = React.useState<boolean>(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<AdminDashboardProps> = ({
{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 }}>
{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' }}>
<Text size={200} color="subtle"></Text>
@@ -962,6 +967,9 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
</DialogBody>
</DialogSurface>
</Dialog>
{imageViewer.open && imageViewer.src ? (
<ImageViewer src={imageViewer.src} alt={imageViewer.alt} onClose={() => setImageViewer({ open: false })} />
) : null}
</div>
) : activeTab === 'complaintReview' ? (
<div>
@@ -1049,11 +1057,11 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
</div>
</div>
{/* 页脚 - 采用 MainLayout 的 Footer 样式 */}
{/* 页脚 */}
<footer className={styles.footer}>
<Text size={200} color="subtle">
Powered By Sycamore_Whisper
</Text>
<div style={{ color: tokens.colorNeutralForeground2, fontSize: tokens.fontSizeBase200, lineHeight: '20px', textAlign: 'center' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{SITE_FOOTER_MD}</ReactMarkdown>
</div>
</footer>
</div>
);

View File

@@ -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<AdminLoginProps> = ({ 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<AdminLoginProps> = ({ 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<AdminLoginProps> = ({ onLoginSuccess }) => {
/>
</Field>
<Checkbox
checked={remember}
onChange={(_, data) => setRemember(!!data.checked)}
label="记住密码"
disabled={loading}
/>
<Button
appearance="primary"
size="large"
@@ -169,6 +184,14 @@ const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
'登录'
)}
</Button>
<div className={styles.backHomeContainer}>
<Link to="/" style={{ textDecoration: 'none' }}>
<Button appearance="transparent" icon={<ArrowLeft24Regular />}></Button>
</Link>
</div>
</div>
</CardPreview>
</Card>

View File

@@ -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<AdminPostCardProps> = ({
}) => {
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 (
<>
<Card className={styles.card}>
<div className={styles.header}>
<Text size={300} weight="semibold"> #{id}</Text>
</div>
<div className={styles.content}>
<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>
<CardFooter>
@@ -159,6 +187,10 @@ const AdminPostCard: React.FC<AdminPostCardProps> = ({
</div>
</CardFooter>
</Card>
{imageViewer.open && imageViewer.src && (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
)}
</>
);
};

View File

@@ -53,10 +53,69 @@ const CreatePost: React.FC<CreatePostProps> = ({ onSubmitSuccess }) => {
};
const handleImageUpload = async (file: File): Promise<string> => {
// 轻度压缩:最大宽高 1920JPEG/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 {
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) {

View 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) {
// 降级:无法 fetchCORS/网络)时打开新标签,让用户自行另存为
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;

View File

@@ -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 (
<Card className={styles.card}>
<div className={styles.content}>
<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>
<CardFooter>
@@ -220,6 +248,10 @@ const PostCard = ({
<ReportPost postId={id} onClose={() => setShowReportModal(false)} />
</div>
)}
{imageViewer.open && imageViewer.src && (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
)}
</Card>
);
};

View File

@@ -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;
// 接下来,请修改默认站点图标

View File

@@ -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 (
<footer className={styles.footer}>
<Text size={200} color="subtle">
Powered By Sycamore_Whisper
</Text>
<div className={styles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{SITE_FOOTER_MD}</ReactMarkdown>
</div>
</footer>
);
};

View File

@@ -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 && (
<Button
appearance="transparent"
icon={<Code24Regular />}
title="项目源代码"
onClick={() => window.open(RepoUrl, '_blank', 'noopener,noreferrer')}
/>
)}
</div>
</header>
);

View File

@@ -1,5 +1,5 @@
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 { Link, useLocation } from 'react-router-dom';
@@ -42,6 +42,7 @@ const menuItems = [
{ path: '/progress/complaint', icon: PeopleSearch24Regular, label: '投诉受理' }
]
},
{ path: '/admin', icon: Settings24Regular, label: '管理面板' },
{ path: '/about', icon: Info24Regular, label: '关于' },
];

View File

@@ -79,7 +79,7 @@ const InitPage: React.FC = () => {
<CardHeader header={<Title2> 😉 </Title2>} />
<CardPreview>
<div className={styles.content}>
<Text weight="semibold">🎊 使 config.py修改配置</Text>
<Text weight="semibold">🎊 使 config.py二次修改配置</Text>
<Field label="管理员令牌">
<Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" />
</Field>

View File

@@ -60,8 +60,8 @@ const NotFound: React.FC = () => {
<div className={styles.preview} />
</CardPreview>
<div className={styles.content}>
<Subtitle1>访</Subtitle1>
<Text>使</Text>
<Subtitle1>Oh no, 😭</Subtitle1>
<Text>使</Text>
<div className={styles.actions}>
<Button appearance="primary" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>