支持 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,
|
||||
|
||||
Reference in New Issue
Block a user