支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

View 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>
);
}

View File

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

View File

@@ -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)
)}

View File

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

View File

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

View File

@@ -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')}

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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>
</>
)}

View 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>
);
}

View File

@@ -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,

View 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;
}

View File

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

View File

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

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>
)}

View File

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

View File

@@ -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>
) : (

View File

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

View File

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

View File

@@ -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); }

View File

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