支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
125
frontend/src/components/BackToTop.tsx
Normal file
125
frontend/src/components/BackToTop.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
/** 滚动超过该距离后显示按钮 */
|
||||
const SHOW_THRESHOLD = 320;
|
||||
/** 路由切换后等待滚动容器挂载的最大重试次数 */
|
||||
const BIND_RETRY_MAX = 24;
|
||||
const BIND_RETRY_MS = 50;
|
||||
|
||||
/**
|
||||
* 定位当前真正滚动的容器。
|
||||
* 前台:.post-list-scroll / .page-wrap / .main-content--compose
|
||||
* 后台:.admin-main
|
||||
*/
|
||||
function pickScrollEl(scope: ParentNode): HTMLElement | null {
|
||||
const list = scope.querySelector<HTMLElement>('.post-list-scroll');
|
||||
if (list) return list;
|
||||
const page = scope.querySelector<HTMLElement>('.page-wrap');
|
||||
if (page) return page;
|
||||
const compose = scope.querySelector<HTMLElement>('.main-content--compose');
|
||||
if (compose) return compose;
|
||||
return scope.querySelector<HTMLElement>('.admin-main');
|
||||
}
|
||||
|
||||
function findScrollScope(): ParentNode | null {
|
||||
return document.querySelector('.main-content')
|
||||
?? document.querySelector('.admin-shell');
|
||||
}
|
||||
|
||||
export default function BackToTop() {
|
||||
const loc = useLocation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const syncVisible = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
setVisible(!!el && el.scrollTop > SHOW_THRESHOLD);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let bound: HTMLElement | null = null;
|
||||
|
||||
const onScroll = () => {
|
||||
if (!cancelled) syncVisible();
|
||||
};
|
||||
|
||||
const unbind = () => {
|
||||
if (bound) {
|
||||
bound.removeEventListener('scroll', onScroll);
|
||||
bound = null;
|
||||
}
|
||||
scrollElRef.current = null;
|
||||
};
|
||||
|
||||
const bind = (el: HTMLElement) => {
|
||||
if (bound === el) {
|
||||
onScroll();
|
||||
return;
|
||||
}
|
||||
unbind();
|
||||
bound = el;
|
||||
scrollElRef.current = el;
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
};
|
||||
|
||||
const tryBind = () => {
|
||||
if (cancelled) return;
|
||||
const scope = findScrollScope();
|
||||
if (!scope) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const next = pickScrollEl(scope);
|
||||
if (next) {
|
||||
bind(next);
|
||||
const waitingList =
|
||||
!next.classList.contains('post-list-scroll') &&
|
||||
!!(scope as Element).querySelector?.('.feed-panel');
|
||||
if (waitingList && attempts < BIND_RETRY_MAX) {
|
||||
attempts += 1;
|
||||
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
unbind();
|
||||
setVisible(false);
|
||||
if (attempts < BIND_RETRY_MAX) {
|
||||
attempts += 1;
|
||||
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
tryBind();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(retryTimer);
|
||||
unbind();
|
||||
};
|
||||
}, [loc.pathname, loc.search, syncVisible]);
|
||||
|
||||
const scrollToTop = () => {
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<ArrowUp size={20} strokeWidth={2.25} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const owoRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inline && replyTo) {
|
||||
@@ -51,13 +52,24 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEmoji) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||
setShowEmoji(false);
|
||||
owoRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowEmoji(false);
|
||||
owoRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [showEmoji]);
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
@@ -108,7 +120,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
<div className="comment-box" ref={boxRef}>
|
||||
<div className="comment-box-avatar">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="comment-box-avatar-img" />
|
||||
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
|
||||
{user ? avatarInitial : (
|
||||
@@ -145,6 +157,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
className="comment-box-send"
|
||||
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
|
||||
onClick={handleSubmit}
|
||||
aria-label="发送评论"
|
||||
title="发送"
|
||||
>
|
||||
<Send size={16} />
|
||||
@@ -200,9 +213,13 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
|
||||
<div className="comment-box-toolbar">
|
||||
<button
|
||||
ref={owoRef}
|
||||
type="button"
|
||||
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
aria-label="插入表情"
|
||||
aria-expanded={showEmoji}
|
||||
aria-controls="comment-emoji-picker"
|
||||
>
|
||||
OwO
|
||||
</button>
|
||||
@@ -212,7 +229,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
|
||||
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ function CommentItem({
|
||||
>
|
||||
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" />
|
||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,64 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { EMOJI_LIST } from '../utils/emojis';
|
||||
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** OwO 表情选择面板 */
|
||||
export default function EmojiPicker({ onSelect }: Props) {
|
||||
/** OwO 表情选择面板(方向键浏览,Enter 选中) */
|
||||
export default function EmojiPicker({ onSelect, id }: Props) {
|
||||
const autoId = useId();
|
||||
const listId = id ?? autoId;
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[active]?.focus();
|
||||
}, [active]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const cols = 8;
|
||||
let next = active;
|
||||
if (e.key === 'ArrowRight') next = Math.min(EMOJI_LIST.length - 1, active + 1);
|
||||
else if (e.key === 'ArrowLeft') next = Math.max(0, active - 1);
|
||||
else if (e.key === 'ArrowDown') next = Math.min(EMOJI_LIST.length - 1, active + cols);
|
||||
else if (e.key === 'ArrowUp') next = Math.max(0, active - cols);
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = EMOJI_LIST.length - 1;
|
||||
else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(EMOJI_LIST[active]);
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="emoji-picker">
|
||||
{EMOJI_LIST.map((e) => (
|
||||
<div
|
||||
id={listId}
|
||||
ref={listRef}
|
||||
className="emoji-picker"
|
||||
role="listbox"
|
||||
aria-label="表情列表"
|
||||
aria-activedescendant={`${listId}-opt-${active}`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{EMOJI_LIST.map((e, i) => (
|
||||
<button
|
||||
key={e}
|
||||
id={`${listId}-opt-${i}`}
|
||||
type="button"
|
||||
className="emoji-picker-item"
|
||||
role="option"
|
||||
tabIndex={active === i ? 0 : -1}
|
||||
aria-selected={active === i}
|
||||
className={`emoji-picker-item${active === i ? ' emoji-picker-item--active' : ''}`}
|
||||
aria-label={e}
|
||||
onClick={() => onSelect(e)}
|
||||
onFocus={() => setActive(i)}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
|
||||
@@ -19,9 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<div className="error-boundary">
|
||||
<h3>页面渲染出错</h3>
|
||||
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p>
|
||||
<p className="error-boundary-msg">{this.state.error.message}</p>
|
||||
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||
刷新页面
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { Clock, MessageCircle, Flame } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
||||
|
||||
export type FeedSort = 'latest' | 'reply' | 'hot';
|
||||
|
||||
@@ -38,14 +40,35 @@ export function feedSortLabel(sort: FeedSort): string {
|
||||
}
|
||||
|
||||
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
|
||||
if (next == null) return;
|
||||
e.preventDefault();
|
||||
onChange(SORT_OPTIONS[next].key);
|
||||
requestAnimationFrame(() => {
|
||||
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
tabs?.[next]?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="feed-toolbar">
|
||||
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
|
||||
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="feed-sort-bar"
|
||||
role="tablist"
|
||||
aria-label="帖子排序"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={activeIndex === i ? 0 : -1}
|
||||
aria-selected={value === key}
|
||||
title={`${label} · ${hint}`}
|
||||
className={cn('feed-sort-tab', value === key && 'active')}
|
||||
|
||||
@@ -22,9 +22,11 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="post-row" onClick={onClick}>
|
||||
<button type="button" className="post-row" onClick={onClick}>
|
||||
<div className="post-avatar">
|
||||
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial}
|
||||
{post.user?.avatar
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</div>
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
@@ -47,6 +49,6 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
{likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
@@ -8,6 +8,7 @@ import { api } from '../api/client';
|
||||
import type { PostRevision } from '../api/types';
|
||||
import PostContent from './PostContent';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import {
|
||||
type PostSnapshot,
|
||||
htmlToDiffText,
|
||||
@@ -27,6 +28,12 @@ interface Props {
|
||||
|
||||
type ViewMode = 'diff' | 'before' | 'after';
|
||||
|
||||
const VIEW_MODES = [
|
||||
['diff', GitCompare, '变更对比'],
|
||||
['before', FileText, '编辑前'],
|
||||
['after', ArrowRight, '编辑后'],
|
||||
] as const;
|
||||
|
||||
interface RevisionEntry {
|
||||
rev: PostRevision;
|
||||
after: PostSnapshot;
|
||||
@@ -105,6 +112,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('diff');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const viewTabsRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => onClose(), [onClose]);
|
||||
useOverlayA11y(open, handleClose, panelRef, { initialFocusRef: closeRef });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -124,7 +137,6 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, postId]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -166,9 +178,10 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
return (
|
||||
<div
|
||||
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
|
||||
onClick={onClose}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
@@ -177,7 +190,7 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
>
|
||||
<header className="post-revision-head">
|
||||
<div className="post-revision-head-left">
|
||||
<History size={18} />
|
||||
<History size={18} aria-hidden />
|
||||
<h3>编辑历史</h3>
|
||||
{selected && (
|
||||
<span className="post-revision-head-sub">
|
||||
@@ -186,21 +199,33 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
)}
|
||||
</div>
|
||||
<div className="post-revision-head-actions">
|
||||
<div className="post-revision-view-tabs" role="tablist">
|
||||
{([
|
||||
['diff', GitCompare, '变更对比'],
|
||||
['before', FileText, '编辑前'],
|
||||
['after', ArrowRight, '编辑后'],
|
||||
] as const).map(([mode, Icon, label]) => (
|
||||
<div
|
||||
ref={viewTabsRef}
|
||||
className="post-revision-view-tabs"
|
||||
role="tablist"
|
||||
aria-label="视图模式"
|
||||
onKeyDown={(e) => {
|
||||
const idx = VIEW_MODES.findIndex(([m]) => m === viewMode);
|
||||
const next = moveTabIndex(e.key, Math.max(0, idx), VIEW_MODES.length);
|
||||
if (next == null) return;
|
||||
e.preventDefault();
|
||||
setViewMode(VIEW_MODES[next][0]);
|
||||
requestAnimationFrame(() => {
|
||||
viewTabsRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{VIEW_MODES.map(([mode, Icon, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={viewMode === mode ? 0 : -1}
|
||||
aria-selected={viewMode === mode}
|
||||
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<Icon size={14} aria-hidden />
|
||||
<span className="post-revision-tab-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -212,10 +237,16 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
|
||||
title={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
{fullscreen ? <Minimize2 size={18} aria-hidden /> : <Maximize2 size={18} aria-hidden />}
|
||||
</button>
|
||||
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} />
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="post-revision-icon-btn"
|
||||
onClick={handleClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -6,6 +6,8 @@ interface Props {
|
||||
notifications: Notification[];
|
||||
online: OnlineStats | null;
|
||||
onPostClick: (id: number) => void;
|
||||
/** 首次拉取中,避免空态闪烁 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
@@ -15,7 +17,13 @@ function hotRankClass(index: number): string {
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
|
||||
export default function RightPanel({
|
||||
hot,
|
||||
notifications,
|
||||
online,
|
||||
onPostClick,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
@@ -28,13 +36,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{hotList.length === 0 ? (
|
||||
{loading && hotList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,13 +60,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
最新动态
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{noticeList.length === 0 ? (
|
||||
{loading && noticeList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
) : noticeList.length === 0 ? (
|
||||
<div className="widget-empty">暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item widget-item--notice"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,15 +88,21 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div className="widget-online-list">
|
||||
{members.map(u => (
|
||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" />
|
||||
: (u.nickname?.[0] || '?')}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||
{loading && online == null ? (
|
||||
<span className="widget-empty widget-empty--inline">加载中…</span>
|
||||
) : (
|
||||
<>
|
||||
{members.map(u => (
|
||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (u.nickname?.[0] || '?')}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
|
||||
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
||||
</nav>
|
||||
|
||||
{boards.length > 0 && (
|
||||
@@ -96,9 +96,9 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>管理</div>
|
||||
<div className="sidebar-section sidebar-section--spaced">管理</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
|
||||
{navItem('admin', '管理后台', <LayoutDashboard aria-hidden />, () => nav('/admin/dashboard'))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
133
frontend/src/components/TagInput.tsx
Normal file
133
frontend/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Tag, X } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export function parseTags(raw: string): string[] {
|
||||
return raw.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function serializeTags(list: string[]): string {
|
||||
return list.join(',');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** 序列化后的总长度上限,0/undefined 表示不限 */
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 回车 / 逗号确认标签块,悬停显示删除 */
|
||||
export default function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '输入标签后回车',
|
||||
maxLength,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tags = parseTags(value);
|
||||
const commit = (raw: string) => {
|
||||
const next = raw.trim();
|
||||
if (!next) return false;
|
||||
|
||||
if (tags.some((t) => t.toLowerCase() === next.toLowerCase())) {
|
||||
setDraft('');
|
||||
return false;
|
||||
}
|
||||
|
||||
const merged = serializeTags([...tags, next]);
|
||||
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
|
||||
notify.warning(`标签总长不能超过 ${maxLength} 字`);
|
||||
return false;
|
||||
}
|
||||
|
||||
onChange(merged);
|
||||
setDraft('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
onChange(serializeTags(tags.filter((_, i) => i !== index)));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && !draft && tags.length > 0) {
|
||||
e.preventDefault();
|
||||
removeAt(tags.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onDraftChange = (text: string) => {
|
||||
// 粘贴或输入含分隔符时立即拆成多个标签
|
||||
if (/[,,]/.test(text)) {
|
||||
const parts = parseTags(text);
|
||||
const lastSep = Math.max(text.lastIndexOf(','), text.lastIndexOf(','));
|
||||
const trailing = lastSep >= 0 && lastSep === text.length - 1 ? '' : text.slice(lastSep + 1);
|
||||
let list = [...tags];
|
||||
for (const p of parts) {
|
||||
if (list.some((t) => t.toLowerCase() === p.toLowerCase())) continue;
|
||||
const merged = serializeTags([...list, p]);
|
||||
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
|
||||
notify.warning(`标签总长不能超过 ${maxLength} 字`);
|
||||
break;
|
||||
}
|
||||
list = [...list, p];
|
||||
}
|
||||
onChange(serializeTags(list));
|
||||
setDraft(trailing.replace(/^[,,]+/, '').trimStart());
|
||||
return;
|
||||
}
|
||||
setDraft(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`compose-tags-field${disabled ? ' compose-tags-field--disabled' : ''}`}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<Tag className="compose-tags-icon" size={16} aria-hidden />
|
||||
<div className="compose-tags-chips">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={`${tag}-${i}`} className="compose-tag-chip">
|
||||
<span className="compose-tag-chip-label">{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="compose-tag-chip-remove"
|
||||
aria-label={`删除标签 ${tag}`}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeAt(i);
|
||||
}}
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="compose-tags-input"
|
||||
placeholder={tags.length === 0 ? placeholder : '继续添加…'}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => {
|
||||
if (draft.trim()) commit(draft);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
@@ -46,12 +47,17 @@ export default function VirtualPostList({
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
overscan: 8,
|
||||
measureElement:
|
||||
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
|
||||
? (el) => el.getBoundingClientRect().height
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isLoadingMore = loading && posts.length > 0;
|
||||
const isEmpty = !loading && posts.length === 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
@@ -92,6 +98,12 @@ export default function VirtualPostList({
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<div className="empty-feed">
|
||||
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无帖子</p>
|
||||
<p className="empty-feed-hint">换个板块看看,或发第一篇内容</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
@@ -100,6 +112,8 @@ export default function VirtualPostList({
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
85
frontend/src/hooks/useOverlayA11y.ts
Normal file
85
frontend/src/hooks/useOverlayA11y.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function listFocusable(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮层无障碍:Escape 关闭、Tab 焦点陷阱、打开时聚焦、关闭后归还焦点。
|
||||
*/
|
||||
export function useOverlayA11y(
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
options?: {
|
||||
/** 打开时优先聚焦的元素 */
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
/** 关闭后是否归还焦点,默认 true */
|
||||
restoreFocus?: boolean;
|
||||
},
|
||||
) {
|
||||
const prevFocusRef = useRef<HTMLElement | null>(null);
|
||||
const restore = options?.restoreFocus !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
prevFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const container = containerRef.current;
|
||||
const initial =
|
||||
options?.initialFocusRef?.current
|
||||
?? container?.querySelector<HTMLElement>(FOCUSABLE)
|
||||
?? null;
|
||||
// 推迟到下一帧,确保抽屉 DOM 已挂载
|
||||
const focusTimer = requestAnimationFrame(() => initial?.focus());
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab' || !container) return;
|
||||
const nodes = listFocusable(container);
|
||||
if (nodes.length === 0) return;
|
||||
const first = nodes[0];
|
||||
const last = nodes[nodes.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
cancelAnimationFrame(focusTimer);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
if (restore) {
|
||||
prevFocusRef.current?.focus?.();
|
||||
}
|
||||
};
|
||||
}, [open, onClose, containerRef, options?.initialFocusRef, restore]);
|
||||
}
|
||||
|
||||
/** tablist 方向键 / Home / End 切换 */
|
||||
export function moveTabIndex(
|
||||
key: string,
|
||||
current: number,
|
||||
length: number,
|
||||
): number | null {
|
||||
if (length <= 0) return null;
|
||||
if (key === 'ArrowRight' || key === 'ArrowDown') return (current + 1) % length;
|
||||
if (key === 'ArrowLeft' || key === 'ArrowUp') return (current - 1 + length) % length;
|
||||
if (key === 'Home') return 0;
|
||||
if (key === 'End') return length - 1;
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, Moon, Sun, Menu, X,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
@@ -20,7 +23,17 @@ const NAV = [
|
||||
/** React 管理后台布局,与前台 SPA 风格统一 */
|
||||
export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const nav = useNavigate();
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeNav = useCallback(() => setNavOpen(false), []);
|
||||
useOverlayA11y(isNarrow && navOpen, closeNav, drawerRef, {
|
||||
initialFocusRef: closeRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
@@ -34,15 +47,50 @@ export default function AdminLayout() {
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNarrow) setNavOpen(false);
|
||||
}, [isNarrow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(isNarrow && navOpen)) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [isNarrow, navOpen]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user || user.role !== 'admin') return null;
|
||||
|
||||
const navLinks = NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
||||
onClick={closeNav}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
{label}
|
||||
</NavLink>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-brand">
|
||||
{isNarrow && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-label={navOpen ? '关闭导航' : '打开导航'}
|
||||
aria-expanded={navOpen}
|
||||
aria-controls="admin-nav-drawer"
|
||||
onClick={() => setNavOpen(v => !v)}
|
||||
>
|
||||
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
|
||||
</button>
|
||||
)}
|
||||
<div className="admin-topbar-mark">姜</div>
|
||||
<div>
|
||||
<div className="admin-topbar-title">姜十三论坛</div>
|
||||
@@ -50,48 +98,77 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
|
||||
</button>
|
||||
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
|
||||
<ArrowLeft size={16} />
|
||||
返回论坛
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
{!isNarrow && '返回论坛'}
|
||||
</button>
|
||||
<span className="admin-topbar-user">{user.nickname}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-body">
|
||||
<aside className="admin-sidebar">
|
||||
{NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
{!isNarrow && (
|
||||
<aside className="admin-sidebar">
|
||||
{navLinks}
|
||||
</aside>
|
||||
)}
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isNarrow && navOpen && (
|
||||
<div className="admin-nav-drawer-root">
|
||||
<button
|
||||
type="button"
|
||||
className="aside-drawer-backdrop"
|
||||
aria-label="关闭导航"
|
||||
tabIndex={-1}
|
||||
onClick={closeNav}
|
||||
/>
|
||||
<aside
|
||||
id="admin-nav-drawer"
|
||||
ref={drawerRef}
|
||||
className="admin-nav-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="管理导航"
|
||||
>
|
||||
<div className="admin-nav-drawer-head">
|
||||
<span>管理导航</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-label="关闭"
|
||||
onClick={closeNav}
|
||||
>
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="admin-nav-drawer-body">
|
||||
{navLinks}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BackToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 管理页通用权限守卫 */
|
||||
/** 管理页就绪状态(鉴权由 AdminLayout 负责,此处不再重复跳转) */
|
||||
export function useAdminGuard() {
|
||||
const { user, loading } = useAuth();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) nav('/login');
|
||||
else if (user.role !== 'admin') {
|
||||
notify.warning('需要管理员权限');
|
||||
nav('/');
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { Moon, Sun, Search, Plus } from 'lucide-react';
|
||||
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
||||
import { api } from '../api/client';
|
||||
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
@@ -27,6 +29,7 @@ export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
@@ -34,17 +37,38 @@ export default function MainLayout() {
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
|
||||
const [hot, setHot] = useState<PostItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [asideOpen, setAsideOpen] = useState(false);
|
||||
const [asideLoading, setAsideLoading] = useState(false);
|
||||
const asideEverLoaded = useRef(false);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
const feedSort = parseFeedSort(params.get('sort'));
|
||||
const { limits: forumLimits } = useForumLimits();
|
||||
|
||||
const asideDrawerRef = useRef<HTMLElement>(null);
|
||||
const asideCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const boardBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closeAside = useCallback(() => setAsideOpen(false), []);
|
||||
useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, {
|
||||
initialFocusRef: asideCloseRef,
|
||||
});
|
||||
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
|
||||
useEffect(() => {
|
||||
if (!hideAside) setAsideOpen(false);
|
||||
}, [hideAside]);
|
||||
useEffect(() => {
|
||||
if (!asideOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [asideOpen]);
|
||||
|
||||
const refreshBoards = useCallback(() => {
|
||||
Promise.all([
|
||||
@@ -59,7 +83,7 @@ export default function MainLayout() {
|
||||
setCachedStats(next);
|
||||
return next;
|
||||
}).catch(() => null),
|
||||
]).finally(() => setLayoutReady(true));
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const refreshOnline = useCallback(() => {
|
||||
@@ -75,20 +99,45 @@ export default function MainLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshBoards();
|
||||
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
|
||||
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
|
||||
refreshOnline();
|
||||
api.presence().catch(() => {});
|
||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||
const onRefresh = () => refreshBoards();
|
||||
window.addEventListener('boards-refresh', onRefresh);
|
||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||
}, [refreshBoards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompose) return;
|
||||
api.presence().catch(() => {});
|
||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||
return () => clearInterval(presenceTimer);
|
||||
}, [isCompose]);
|
||||
|
||||
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
||||
useEffect(() => {
|
||||
if (!needAsideData) return;
|
||||
let cancelled = false;
|
||||
if (!asideEverLoaded.current) setAsideLoading(true);
|
||||
|
||||
Promise.all([
|
||||
api.hotPosts().then(d => {
|
||||
if (!cancelled) setHot(Array.isArray(d.posts) ? d.posts : []);
|
||||
}).catch(() => {}),
|
||||
api.notifications().then(d => {
|
||||
if (!cancelled) setNotifications(Array.isArray(d.notifications) ? d.notifications : []);
|
||||
}).catch(() => {}),
|
||||
]).finally(() => {
|
||||
if (!cancelled) {
|
||||
asideEverLoaded.current = true;
|
||||
setAsideLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
refreshOnline();
|
||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(onlineTimer);
|
||||
clearInterval(presenceTimer);
|
||||
window.removeEventListener('boards-refresh', onRefresh);
|
||||
};
|
||||
}, [refreshBoards, refreshOnline]);
|
||||
}, [needAsideData, refreshOnline]);
|
||||
|
||||
const doSearch = () => {
|
||||
const kw = keyword.trim();
|
||||
@@ -108,9 +157,34 @@ export default function MainLayout() {
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
};
|
||||
|
||||
const openPost = (id: number) => {
|
||||
setAsideOpen(false);
|
||||
nav(`/post/${id}`);
|
||||
};
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
const isFeedHome = loc.pathname === '/';
|
||||
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
|
||||
|
||||
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
|
||||
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
|
||||
|
||||
const selectBoardChip = (id: number) => {
|
||||
setBoardId(id);
|
||||
navigateFeed(nav, buildHomeUrl(id, feedSort));
|
||||
};
|
||||
|
||||
const onBoardBarKeyDown = (e: React.KeyboardEvent) => {
|
||||
const next = moveTabIndex(e.key, activeChipIndex, boardChipIds.length);
|
||||
if (next == null) return;
|
||||
e.preventDefault();
|
||||
selectBoardChip(boardChipIds[next]);
|
||||
requestAnimationFrame(() => {
|
||||
const tabs = boardBarRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
tabs?.[next]?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app-frame">
|
||||
@@ -123,11 +197,12 @@ export default function MainLayout() {
|
||||
|
||||
{!isCompose && (
|
||||
<div className="header-search-wrap">
|
||||
<Search className="header-search-icon" size={16} />
|
||||
<Search className="header-search-icon" size={16} aria-hidden />
|
||||
<input
|
||||
className="header-search-input"
|
||||
type="search"
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
@@ -150,20 +225,36 @@ export default function MainLayout() {
|
||||
type="button"
|
||||
className="header-compose-btn"
|
||||
onClick={() => user ? nav('/compose') : nav('/login')}
|
||||
aria-label="发帖"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<Plus size={16} aria-hidden />
|
||||
{!isMobile && <span>发帖</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="header-action-group">
|
||||
{!isCompose && hideAside && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={() => setAsideOpen(true)}
|
||||
aria-label="打开社区动态"
|
||||
aria-expanded={asideOpen}
|
||||
aria-controls="aside-drawer"
|
||||
title="社区动态"
|
||||
>
|
||||
<PanelRight size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
|
||||
</button>
|
||||
|
||||
{authLoading ? (
|
||||
@@ -171,9 +262,9 @@ export default function MainLayout() {
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="header-user-btn" title={user.nickname}>
|
||||
<button type="button" className="header-user-btn" title={user.nickname} aria-label={`用户菜单:${user.nickname}`}>
|
||||
{user.avatar
|
||||
? <img src={user.avatar} alt="" className="header-user-avatar" />
|
||||
? <img src={user.avatar} alt="" className="header-user-avatar" loading="lazy" decoding="async" />
|
||||
: <span className="header-user-initial">{userInitial}</span>}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -217,25 +308,40 @@ export default function MainLayout() {
|
||||
|
||||
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
|
||||
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
|
||||
{isMobile && !isCompose && (
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
{isMobile && !isCompose && isFeedHome && (
|
||||
<div
|
||||
ref={boardBarRef}
|
||||
className="mobile-board-bar"
|
||||
role="tablist"
|
||||
aria-label="板块"
|
||||
onKeyDown={onBoardBarKeyDown}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={activeChipIndex === 0 ? 0 : -1}
|
||||
aria-selected={mobileActiveBoard === 0}
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
|
||||
>全部</span>
|
||||
{boards.map(b => {
|
||||
onClick={() => selectBoardChip(0)}
|
||||
>全部</button>
|
||||
{boards.map((b, i) => {
|
||||
const themeIdx = getBoardThemeIndex(b);
|
||||
const isActive = mobileActiveBoard === b.id;
|
||||
const idx = i + 1;
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={activeChipIndex === idx ? 0 : -1}
|
||||
aria-selected={isActive}
|
||||
className={cn(
|
||||
'board-chip',
|
||||
isActive && 'active',
|
||||
isActive && `board-chip--${themeIdx}`,
|
||||
)}
|
||||
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
|
||||
>{b.name}</span>
|
||||
onClick={() => selectBoardChip(b.id)}
|
||||
>{b.name}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -247,7 +353,6 @@ export default function MainLayout() {
|
||||
setBoardId,
|
||||
boards,
|
||||
stats,
|
||||
layoutReady,
|
||||
refreshBoards,
|
||||
isMobile,
|
||||
} satisfies LayoutCtx} />
|
||||
@@ -260,13 +365,58 @@ export default function MainLayout() {
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
onPostClick={(id) => nav(`/post/${id}`)}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asideOpen && hideAside && !isCompose && (
|
||||
<div className="aside-drawer-root">
|
||||
<button
|
||||
type="button"
|
||||
className="aside-drawer-backdrop"
|
||||
aria-label="关闭社区动态"
|
||||
tabIndex={-1}
|
||||
onClick={closeAside}
|
||||
/>
|
||||
<aside
|
||||
id="aside-drawer"
|
||||
ref={asideDrawerRef}
|
||||
className="aside-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="社区动态"
|
||||
>
|
||||
<div className="aside-drawer-head">
|
||||
<span>社区动态</span>
|
||||
<button
|
||||
ref={asideCloseRef}
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-label="关闭"
|
||||
onClick={closeAside}
|
||||
>
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="aside-drawer-body">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BackToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,7 +427,6 @@ export type LayoutCtx = {
|
||||
setBoardId: (id: number) => void;
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
layoutReady: boolean;
|
||||
refreshBoards: () => void;
|
||||
isMobile: boolean;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, FolderKanban } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -132,7 +132,7 @@ export default function BoardsManagePage() {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div className="admin-page-head-row">
|
||||
<div>
|
||||
<h1>板块管理</h1>
|
||||
<p>创建、编辑或删除论坛板块;可为每个板块自定义图标与色标</p>
|
||||
@@ -209,6 +209,7 @@ export default function BoardsManagePage() {
|
||||
</Table>
|
||||
{boards.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<FolderKanban className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>还没有板块,点击右上角创建第一个</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Send, Tag } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
@@ -10,7 +10,10 @@ import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
|
||||
import ArticleEditor from '../components/ArticleEditor';
|
||||
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
|
||||
import TagInput, { serializeTags, parseTags } from '../components/TagInput';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getCachedBoards } from '../utils/layoutCache';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
|
||||
interface ComposeBaseline {
|
||||
title: string;
|
||||
@@ -19,6 +22,11 @@ interface ComposeBaseline {
|
||||
boardId: string;
|
||||
}
|
||||
|
||||
function resolveBoards(ctxBoards?: Board[]): Board[] {
|
||||
if (ctxBoards && ctxBoards.length > 0) return ctxBoards;
|
||||
return getCachedBoards();
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
const { id: editIdParam } = useParams();
|
||||
@@ -28,14 +36,19 @@ export default function ComposePage() {
|
||||
const defaultBoard = params.get('board') || '';
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const layoutCtx = useOutletContext<LayoutCtx | undefined>();
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boards, setBoards] = useState<Board[]>(() => resolveBoards(layoutCtx?.boards));
|
||||
const [boardId, setBoardId] = useState(defaultBoard);
|
||||
const [title, setTitle] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
|
||||
const [boardsReady, setBoardsReady] = useState(
|
||||
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
|
||||
);
|
||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +57,11 @@ export default function ComposePage() {
|
||||
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
Promise.all([api.boards(), api.post(editId!, { skipView: true })])
|
||||
const cached = resolveBoards(layoutCtx?.boards);
|
||||
const boardsPromise = cached.length > 0
|
||||
? Promise.resolve({ boards: cached })
|
||||
: api.boards();
|
||||
Promise.all([boardsPromise, api.post(editId!, { skipView: true })])
|
||||
.then(([boardsData, postData]) => {
|
||||
const list = boardsData.boards ?? [];
|
||||
setBoards(list);
|
||||
@@ -80,11 +97,27 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
api.boards().then(d => {
|
||||
const list = d.boards ?? [];
|
||||
const list = resolveBoards(layoutCtx?.boards);
|
||||
if (list.length > 0) {
|
||||
setBoards(list);
|
||||
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
|
||||
if (!defaultBoard && list.length > 0) {
|
||||
setBoardsReady(true);
|
||||
const initialBoardId = defaultBoard || String(list[0].id);
|
||||
if (!defaultBoard) setBoardId(initialBoardId);
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBoardsReady(false);
|
||||
api.boards().then(d => {
|
||||
const next = d.boards ?? [];
|
||||
setBoards(next);
|
||||
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
|
||||
if (!defaultBoard && next.length > 0) {
|
||||
setBoardId(initialBoardId);
|
||||
}
|
||||
setBaseline({
|
||||
@@ -93,14 +126,16 @@ export default function ComposePage() {
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
|
||||
}).catch(() => {
|
||||
setBoards([]);
|
||||
}).finally(() => setBoardsReady(true));
|
||||
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return (
|
||||
title !== baseline.title
|
||||
|| tags !== baseline.tags
|
||||
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|
||||
|| content !== baseline.content
|
||||
|| (!isEdit && boardId !== baseline.boardId)
|
||||
);
|
||||
@@ -124,7 +159,7 @@ export default function ComposePage() {
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (loading) {
|
||||
if (loading || (!isEdit && !boardsReady)) {
|
||||
return (
|
||||
<div className="compose-page compose-page--empty">
|
||||
<Spinner size="lg" />
|
||||
@@ -136,7 +171,9 @@ export default function ComposePage() {
|
||||
return (
|
||||
<div className="compose-page compose-page--empty">
|
||||
<div className="compose-empty-card">
|
||||
<div className="compose-empty-icon">✎</div>
|
||||
<div className="compose-empty-icon" aria-hidden>
|
||||
<Pencil size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2>暂无可发帖板块</h2>
|
||||
<p>需要管理员先创建板块后才能发布内容</p>
|
||||
{user.role === 'admin' ? (
|
||||
@@ -164,7 +201,7 @@ export default function ComposePage() {
|
||||
const payload = {
|
||||
title: trimmedTitle,
|
||||
content: content.trim(),
|
||||
tags: tags.trim(),
|
||||
tags: serializeTags(parseTags(tags)),
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
@@ -230,16 +267,12 @@ export default function ComposePage() {
|
||||
<span className="compose-board-pill active">{currentBoard.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="compose-tags-field">
|
||||
<Tag className="compose-tags-icon" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="添加标签,逗号分隔"
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||
/>
|
||||
</div>
|
||||
<TagInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
placeholder="输入标签后回车"
|
||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="compose-writing">
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { formatTime } from '../utils/content';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
|
||||
interface FavItem {
|
||||
id: number;
|
||||
post_id: number;
|
||||
created_at: string;
|
||||
post?: {
|
||||
id: number;
|
||||
title: string;
|
||||
board?: { name: string };
|
||||
user?: { nickname: string };
|
||||
};
|
||||
post?: PostItem;
|
||||
}
|
||||
|
||||
export default function FavoritesPage() {
|
||||
@@ -30,7 +26,7 @@ export default function FavoritesPage() {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
api.favorites()
|
||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : []))
|
||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, authLoading, nav]);
|
||||
@@ -51,26 +47,31 @@ export default function FavoritesPage() {
|
||||
|
||||
{list.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Star className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>还没有收藏任何帖子</p>
|
||||
<Button onClick={() => nav('/')}>去逛逛</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="content-surface">
|
||||
{list.map(fav => (
|
||||
<div
|
||||
key={fav.id}
|
||||
className="post-row"
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
>
|
||||
<div className="post-body">
|
||||
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
|
||||
<div className="post-meta">
|
||||
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
|
||||
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
|
||||
<span>收藏于 {formatTime(fav.created_at)}</span>
|
||||
fav.post ? (
|
||||
<PostListItem
|
||||
key={fav.id}
|
||||
post={fav.post}
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={fav.id}
|
||||
type="button"
|
||||
className="post-row"
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
>
|
||||
<div className="post-body">
|
||||
<div className="post-title">帖子已删除</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
|
||||
<p className="auth-footer">
|
||||
没有账号?<Link to="/register">注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -46,40 +46,51 @@ export default function PostDetailPage() {
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
return Array.isArray(comm.comments) ? comm.comments : [];
|
||||
}, [postId, user]);
|
||||
|
||||
const load = async () => {
|
||||
if (!postId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [detail, commList] = await Promise.all([
|
||||
api.post(postId),
|
||||
fetchComments(),
|
||||
]);
|
||||
setPost(detail.post);
|
||||
setLiked(detail.liked);
|
||||
setFavorited(detail.favorited);
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setComments(commList);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const loadSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
setReplyTo(null);
|
||||
load();
|
||||
const seq = ++loadSeq.current;
|
||||
setLoading(true);
|
||||
setPost(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const [detail, comm] = await Promise.all([
|
||||
api.post(postId),
|
||||
api.comments(postId, myIds),
|
||||
]);
|
||||
if (seq !== loadSeq.current) return;
|
||||
setPost(detail.post);
|
||||
setLiked(detail.liked);
|
||||
setFavorited(detail.favorited);
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
// 会话刷新与正文展示解耦;勿作为 effect 依赖
|
||||
void refresh();
|
||||
} catch (e: unknown) {
|
||||
if (seq !== loadSeq.current) return;
|
||||
setPost(null);
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
// 仅 postId 变化时加载;user/refresh 变化不得重跑
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
|
||||
}, [postId]);
|
||||
|
||||
// 发评后局部刷新评论列表(不整页重载)
|
||||
const reloadComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
}, [postId, user]);
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return;
|
||||
@@ -145,7 +156,7 @@ export default function PostDetailPage() {
|
||||
setReplyTo(null);
|
||||
setSubmitCount(c => c + 1);
|
||||
notify.success('评论成功');
|
||||
setComments(await fetchComments());
|
||||
await reloadComments();
|
||||
setTimeout(() => jumpToFloor(r.floor), 100);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '评论失败');
|
||||
@@ -165,6 +176,7 @@ export default function PostDetailPage() {
|
||||
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
if (!post) return (
|
||||
<div className="empty-state">
|
||||
<FileQuestion className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>帖子不存在</p>
|
||||
<Button variant="outline" onClick={() => nav('/')}>返回首页</Button>
|
||||
</div>
|
||||
@@ -226,7 +238,7 @@ export default function PostDetailPage() {
|
||||
</h1>
|
||||
<div className="post-detail-author-row">
|
||||
<div className="post-avatar post-avatar-lg">
|
||||
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : authorInitial}
|
||||
{post.user?.avatar ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> : authorInitial}
|
||||
</div>
|
||||
<div className="post-detail-author-info">
|
||||
<span className="post-detail-author-name">{post.user?.nickname}</span>
|
||||
@@ -318,7 +330,7 @@ export default function PostDetailPage() {
|
||||
<div className="comment-list-area">
|
||||
{comments.length === 0 && !replyTo ? (
|
||||
<div className="comment-empty">
|
||||
<div className="comment-empty-icon">💬</div>
|
||||
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
||||
<p>暂无评论,来抢沙发吧</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
|
||||
<div className="page-inner-wide page-inner-wide--profile">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
@@ -244,7 +244,7 @@ export default function ProfilePage() {
|
||||
>
|
||||
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
|
||||
{displayAvatar
|
||||
? <img src={displayAvatar} alt="" />
|
||||
? <img src={displayAvatar} alt="" loading="lazy" decoding="async" />
|
||||
: user.nickname[0]}
|
||||
<span className="profile-avatar-overlay">
|
||||
{avatarLoading
|
||||
@@ -291,7 +291,7 @@ export default function ProfilePage() {
|
||||
{user.role === 'admin' && (
|
||||
<div className="section-card admin-entry-card">
|
||||
<div className="section-card-title">管理员入口</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
|
||||
<p className="admin-entry-desc">
|
||||
管理板块、用户、帖子及系统设置
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -364,7 +364,7 @@ export default function ProfilePage() {
|
||||
<FormItem>
|
||||
<FormLabel>新密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="至少 6 位" {...field} />
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function RegisterPage() {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
|
||||
<p className="auth-footer">
|
||||
已有账号?<Link to="/login">登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -600,6 +600,64 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
|
||||
/* 窄屏社区动态抽屉(替代隐藏的右侧栏) */
|
||||
.aside-drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
.aside-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aside-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(360px, 92vw);
|
||||
background: var(--j13-bg-workspace);
|
||||
border-left: 1px solid var(--j13-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
|
||||
animation: aside-drawer-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes aside-drawer-in {
|
||||
from { transform: translateX(12px); opacity: 0.6; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.aside-drawer-head {
|
||||
height: var(--j13-header-h);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: var(--j13-bg-surface);
|
||||
}
|
||||
|
||||
.aside-drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aside-drawer { animation: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-frame { border-left: none; border-right: none; }
|
||||
.sidebar { display: none; }
|
||||
@@ -607,8 +665,6 @@ a:hover { text-decoration: underline; }
|
||||
.header-search-wrap { max-width: none; }
|
||||
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
|
||||
.feed-banner-row { flex-direction: row; gap: 10px; }
|
||||
.board-grid { flex-wrap: nowrap; overflow-x: auto; padding: 8px 12px; scrollbar-width: none; }
|
||||
.board-grid::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
@@ -623,6 +679,80 @@ a:hover { text-decoration: underline; }
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 回到顶部:贴合 app-frame 右缘,低于顶栏 / dialog */
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
right: max(16px, calc((100vw - min(100vw, var(--j13-max-w))) / 2 + 16px));
|
||||
bottom: 28px;
|
||||
z-index: 90;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 50%;
|
||||
background: var(--j13-bg-surface);
|
||||
color: var(--j13-green);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--j13-shadow-card);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
visibility 0.2s ease,
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.back-to-top--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
border-color: var(--j13-green);
|
||||
box-shadow: 0 3px 12px rgba(26, 127, 75, 0.28);
|
||||
}
|
||||
|
||||
.back-to-top:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.back-to-top {
|
||||
right: 14px;
|
||||
bottom: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.back-to-top {
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.back-to-top--visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.back-to-top:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -679,6 +809,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.page-inner { padding: 20px 24px; max-width: 720px; }
|
||||
.page-inner-wide { padding: 20px 24px; }
|
||||
.page-inner-wide--profile { max-width: 640px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--color-text-3); margin: 0 0 20px; }
|
||||
|
||||
@@ -915,9 +1046,17 @@ a:hover { text-decoration: underline; }
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
background: var(--j13-bg-block);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.board-chip:focus-visible {
|
||||
outline: 2px solid var(--j13-green);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.board-chip.active {
|
||||
@@ -936,14 +1075,6 @@ a:hover { text-decoration: underline; }
|
||||
.board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); }
|
||||
.board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); }
|
||||
|
||||
.board-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
/* 帖子排序栏:与左侧栏 active 样式保持一致 */
|
||||
.feed-sort-bar {
|
||||
display: flex;
|
||||
@@ -1016,89 +1147,6 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
}
|
||||
|
||||
.board-grid-empty {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
.board-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
max-width: 220px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--j13-bg-block);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-tab:hover {
|
||||
border-color: var(--j13-green-light, #7cb87c);
|
||||
background: var(--j13-bg-block-muted);
|
||||
}
|
||||
|
||||
.board-tab.active {
|
||||
border-color: var(--j13-green);
|
||||
background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-block));
|
||||
}
|
||||
|
||||
.board-tab-icon {
|
||||
font-size: 14px;
|
||||
color: var(--j13-green);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-tab-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.board-tab-count {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.board-tab.active .board-tab-count {
|
||||
background: color-mix(in srgb, var(--j13-green) 20%, transparent);
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.board-tab--skeleton {
|
||||
width: 140px;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(90deg, var(--color-fill-2) 25%, var(--color-fill-3) 50%, var(--color-fill-2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: board-skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid--skeleton { pointer-events: none; }
|
||||
|
||||
@keyframes board-skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.post-list-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1112,8 +1160,15 @@ a:hover { text-decoration: underline; }
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
@@ -1136,11 +1191,19 @@ a:hover { text-decoration: underline; }
|
||||
background: var(--j13-bg-block-accent);
|
||||
}
|
||||
|
||||
.post-row:hover::before {
|
||||
.post-row:hover::before,
|
||||
.post-row:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.post-row:hover .post-title {
|
||||
.post-row:focus-visible {
|
||||
outline: none;
|
||||
background: var(--j13-bg-block-accent);
|
||||
box-shadow: inset 0 0 0 2px var(--j13-green-bg);
|
||||
}
|
||||
|
||||
.post-row:hover .post-title,
|
||||
.post-row:focus-visible .post-title {
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
@@ -1161,7 +1224,8 @@ a:hover { text-decoration: underline; }
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.post-row:hover .post-avatar {
|
||||
.post-row:hover .post-avatar,
|
||||
.post-row:focus-visible .post-avatar {
|
||||
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
|
||||
}
|
||||
|
||||
@@ -1210,7 +1274,8 @@ a:hover { text-decoration: underline; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-row:hover .post-stat {
|
||||
.post-row:hover .post-stat,
|
||||
.post-row:focus-visible .post-stat {
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green);
|
||||
}
|
||||
@@ -1445,15 +1510,72 @@ a:hover { text-decoration: underline; }
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 24px; color: var(--color-text-3); }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-feed {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-feed-icon { font-size: 36px; margin-bottom: 8px; }
|
||||
.empty-state-icon,
|
||||
.empty-feed-icon,
|
||||
.comment-empty-icon {
|
||||
display: block;
|
||||
color: var(--color-text-4);
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.comment-empty-icon {
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.empty-feed-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.admin-entry-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.sidebar-section--spaced {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-boundary {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-boundary-msg {
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.post-detail-loading {
|
||||
display: flex;
|
||||
@@ -2472,7 +2594,6 @@ a:hover { text-decoration: underline; }
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.comment-empty-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.6; }
|
||||
.comment-empty p { margin: 0; font-size: 13px; }
|
||||
|
||||
/* 回复栏(旧版保留兼容) */
|
||||
@@ -2751,7 +2872,16 @@ a:hover { text-decoration: underline; }
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.emoji-picker-item:hover { background: var(--color-fill-2); }
|
||||
.emoji-picker-item:hover,
|
||||
.emoji-picker-item:focus-visible,
|
||||
.emoji-picker-item--active {
|
||||
background: var(--color-fill-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-picker-item:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--j13-green);
|
||||
}
|
||||
|
||||
/* Waline 嵌套评论列表 — 与正文共用 .page-wrap 滚动 */
|
||||
|
||||
@@ -3023,10 +3153,16 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
}
|
||||
|
||||
.widget-item {
|
||||
width: 100%;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -3036,7 +3172,8 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
|
||||
.widget-item:last-child { border-bottom: none; }
|
||||
|
||||
.widget-item:hover {
|
||||
.widget-item:hover,
|
||||
.widget-item:focus-visible {
|
||||
color: var(--j13-green);
|
||||
background: var(--j13-bg-block-accent);
|
||||
padding-left: 6px;
|
||||
@@ -3045,6 +3182,11 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.widget-item:focus-visible {
|
||||
outline: 2px solid var(--j13-green);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.widget-item-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -3436,7 +3578,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
border-radius: 14px;
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green);
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -3583,13 +3724,15 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
|
||||
.compose-tags-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 20px;
|
||||
background: var(--j13-bg-surface);
|
||||
min-width: 220px;
|
||||
max-width: 100%;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -3597,23 +3740,111 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
border-color: var(--j13-green);
|
||||
}
|
||||
|
||||
.compose-tags-icon {
|
||||
color: var(--color-text-4);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
.compose-tags-field--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compose-tags-field input {
|
||||
.compose-tags-icon {
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.compose-tags-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compose-tag-chip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--j13-green-bg);
|
||||
color: var(--j13-green);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compose-tag-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.compose-tag-chip-remove {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--j13-bg-surface);
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-2);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.compose-tag-chip:hover .compose-tag-chip-remove,
|
||||
.compose-tag-chip:focus-within .compose-tag-chip-remove {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.compose-tag-chip-remove:hover {
|
||||
background: #e53935;
|
||||
}
|
||||
|
||||
.compose-tag-chip-remove:focus-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
outline: 2px solid var(--j13-green);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 触控设备无悬停:始终显示删除按钮 */
|
||||
@media (hover: none) {
|
||||
.compose-tag-chip-remove {
|
||||
opacity: 0.85;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-tags-input {
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-1);
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.compose-tags-field input::placeholder {
|
||||
.compose-tags-input::placeholder {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
@@ -4297,7 +4528,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
|
||||
.admin-card {
|
||||
border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card));
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 16px; overflow-x: auto;
|
||||
}
|
||||
.admin-card-body { padding: 16px 20px 20px; }
|
||||
.admin-card-head {
|
||||
@@ -4498,6 +4729,83 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.admin-settings-bar p { max-width: none; }
|
||||
.admin-settings-info-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-body {
|
||||
height: calc(100dvh - 56px);
|
||||
}
|
||||
.admin-main {
|
||||
padding: 16px;
|
||||
}
|
||||
.admin-topbar {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-topbar-brand {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-topbar-sub { display: none; }
|
||||
.admin-dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 后台窄屏导航抽屉(与前台 aside-drawer 同范式) */
|
||||
.admin-nav-drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
.admin-nav-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(280px, 88vw);
|
||||
background: hsl(var(--card));
|
||||
border-right: 1px solid var(--j13-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.12);
|
||||
animation: admin-nav-drawer-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes admin-nav-drawer-in {
|
||||
from { transform: translateX(-12px); opacity: 0.6; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.admin-nav-drawer-head {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-nav-drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-nav-drawer { animation: none; }
|
||||
}
|
||||
|
||||
.admin-page-head-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
|
||||
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }
|
||||
|
||||
@@ -91,5 +91,10 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user