feat: 重设计私信页并优化开源展柜体验

私信改为飞书风布局,支持缩略图表情/图片、按会话草稿与用户搜索发起会话;主页发私信直达消息页。开源展柜修复空列表粘滞并改版目录式 UI。
This commit is contained in:
2026-09-02 02:45:29 +08:00
parent 1c0f7ede55
commit 500e452c2f
11 changed files with 1176 additions and 258 deletions

View File

@@ -9,7 +9,6 @@ import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { formatTime } from '../utils/content'; import { formatTime } from '../utils/content';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
import ComposeMessageDialog from './ComposeMessageDialog';
import UserLink from './UserLink'; import UserLink from './UserLink';
interface Props { interface Props {
@@ -28,7 +27,6 @@ export default function PostAuthorCard({
const { user: me } = useAuth(); const { user: me } = useAuth();
const [profile, setProfile] = useState<UserPublic | null>(null); const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null); const [stats, setStats] = useState<UserActivityStats | null>(null);
const [msgOpen, setMsgOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!author?.id) { if (!author?.id) {
@@ -78,7 +76,7 @@ export default function PostAuthorCard({
nav(loginPath(profileHref)); nav(loginPath(profileHref));
return; return;
} }
setMsgOpen(true); nav(`/messages?peer=${author.id}`);
}; };
return ( return (
@@ -159,16 +157,6 @@ export default function PostAuthorCard({
</Button> </Button>
</div> </div>
</div> </div>
{!isSelf && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={author.id}
toNickname={nick}
onSent={() => nav(`/messages?peer=${author.id}`)}
/>
)}
</div> </div>
); );
} }

View File

@@ -31,7 +31,7 @@ function pickScrollEl(): HTMLElement | null {
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)'); const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page; if (page) return page;
const showcase = document.querySelector<HTMLElement>('.showcase-page'); const showcase = document.querySelector<HTMLElement>('.showcase-panel');
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase; if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
const admin = document.querySelector<HTMLElement>('.admin-main'); const admin = document.querySelector<HTMLElement>('.admin-main');

View File

@@ -19,12 +19,14 @@ export default function ShowcaseAsideWidget() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY); const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) { // 非空快照直接用;空数组可能是启动竞态假空,仍再拉
if (hit !== undefined && hit.length > 0) {
setItems(hit); setItems(hit);
setLoading(false); setLoading(false);
return; return;
} }
setLoading(true); if (hit !== undefined) setItems(hit);
setLoading(hit === undefined);
api.communityShowcase() api.communityShowcase()
.then((r) => { .then((r) => {
if (cancelled) return; if (cancelled) return;
@@ -33,10 +35,8 @@ export default function ShowcaseAsideWidget() {
setItems(next); setItems(next);
}) })
.catch(() => { .catch(() => {
if (!cancelled) { // 失败不写空快照,避免假空粘死
setSessionSnapshot(SHOWCASE_KEY, []); if (!cancelled && hit === undefined) setItems([]);
setItems([]);
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@@ -54,17 +54,13 @@ export default function ShowcaseAsideWidget() {
setItems(next); setItems(next);
}) })
.catch(() => { .catch(() => {
if (opts.showLoading) { // 软刷新 / 强刷失败:保持旧 UI不写空快照
setSessionSnapshot(SHOWCASE_KEY, []);
setItems([]);
}
// 软刷新失败:保持旧 UI
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const applyHitOrReload = (showLoading: boolean) => { const applyHitOrReload = (showLoading: boolean) => {
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY); const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) { if (hit !== undefined && hit.length > 0) {
setItems(hit); setItems(hit);
setLoading(false); setLoading(false);
return; return;

View File

@@ -13,15 +13,22 @@ type Fetcher<T> = () => Promise<T>;
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。 * 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。 * 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
*/ */
function isEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length === 0;
}
export function useSessionResource<T>( export function useSessionResource<T>(
key: string | null, key: string | null,
fetcher: Fetcher<T>, fetcher: Fetcher<T>,
opts?: { opts?: {
enabled?: boolean; enabled?: boolean;
onError?: (e: unknown) => void; onError?: (e: unknown) => void;
/** 缓存为空数组时仍后台再拉,避免启动竞态假空粘死 */
revalidateEmpty?: boolean;
}, },
) { ) {
const enabled = opts?.enabled !== false; const enabled = opts?.enabled !== false;
const revalidateEmpty = opts?.revalidateEmpty === true;
const fetcherRef = useRef(fetcher); const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher; fetcherRef.current = fetcher;
const onErrorRef = useRef(opts?.onError); const onErrorRef = useRef(opts?.onError);
@@ -49,7 +56,7 @@ export function useSessionResource<T>(
} }
const hit = getSessionSnapshot<T>(key); const hit = getSessionSnapshot<T>(key);
if (hit !== undefined) { if (hit !== undefined && !(revalidateEmpty && isEmptyArray(hit))) {
setData(hit); setData(hit);
setLoading(false); setLoading(false);
setPending(false); setPending(false);
@@ -57,9 +64,16 @@ export function useSessionResource<T>(
} }
const seq = ++seqRef.current; const seq = ++seqRef.current;
const keep = dataRef.current !== undefined; const keep = dataRef.current !== undefined || (hit !== undefined && isEmptyArray(hit));
if (keep) setPending(true); if (hit !== undefined && isEmptyArray(hit)) {
else setLoading(true); setData(hit);
setPending(true);
setLoading(false);
} else if (keep && dataRef.current !== undefined) {
setPending(true);
} else {
setLoading(true);
}
fetcherRef.current() fetcherRef.current()
.then((next) => { .then((next) => {
@@ -70,6 +84,7 @@ export function useSessionResource<T>(
.catch((e: unknown) => { .catch((e: unknown) => {
if (seq !== seqRef.current) return; if (seq !== seqRef.current) return;
onErrorRef.current?.(e); onErrorRef.current?.(e);
// 失败不写空快照;已有画面则保留
if (!keep) setData(undefined); if (!keep) setData(undefined);
}) })
.finally(() => { .finally(() => {
@@ -81,7 +96,7 @@ export function useSessionResource<T>(
return () => { return () => {
seqRef.current += 1; seqRef.current += 1;
}; };
}, [key, enabled]); }, [key, enabled, revalidateEmpty]);
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => { const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
setData((prev) => { setData((prev) => {
@@ -99,18 +114,20 @@ export function useSessionResource<T>(
const reload = (opts: { allowLoading: boolean }) => { const reload = (opts: { allowLoading: boolean }) => {
if (!key || !enabled) return; if (!key || !enabled) return;
const warm = getSessionSnapshot<T>(key); const warm = getSessionSnapshot<T>(key);
if (warm !== undefined) { if (warm !== undefined && !(revalidateEmpty && isEmptyArray(warm))) {
setData(warm); setData(warm);
setLoading(false); setLoading(false);
setPending(false); setPending(false);
return; return;
} }
const seq = ++seqRef.current; const seq = ++seqRef.current;
const keep = dataRef.current !== undefined; const keep = dataRef.current !== undefined || (warm !== undefined && isEmptyArray(warm));
if (opts.allowLoading) { if (opts.allowLoading) {
deleteSessionSnapshot(key); deleteSessionSnapshot(key);
if (keep) setPending(true); if (keep) setPending(true);
else setLoading(true); else setLoading(true);
} else if (warm !== undefined && isEmptyArray(warm)) {
setPending(true);
} }
fetcherRef.current() fetcherRef.current()
.then((next) => { .then((next) => {
@@ -138,7 +155,7 @@ export function useSessionResource<T>(
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
}; };
}, [key, enabled]); }, [key, enabled, revalidateEmpty]);
return { data, loading, pending, replace, invalidate }; return { data, loading, pending, replace, invalidate };
} }

View File

@@ -6,11 +6,15 @@ import {
Bell, Bell,
CheckCheck, CheckCheck,
Flag, Flag,
ImagePlus,
Inbox, Inbox,
Mail, Mail,
MessageCircleReply, MessageCircleReply,
Search,
Send, Send,
ShieldAlert, ShieldAlert,
Smile,
X,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,10 +25,14 @@ import type { MessageConversation, PrivateMessage, User } from '../api/types';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { useNoIndexSEO } from '../hooks/usePageSEO'; import { useNoIndexSEO } from '../hooks/usePageSEO';
import { formatDateTime, formatTime } from '../utils/content'; import { formatConvListTime, formatDateTime, formatTime } from '../utils/content';
import { postPath } from '../utils/permalink'; import { postPath } from '../utils/permalink';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
import { InFlowSiteFooter } from '../components/SiteFooter'; import { InFlowSiteFooter } from '../components/SiteFooter';
import StickerPicker from '../components/emoji/StickerPicker';
import type { Sticker } from '../data/stickers';
import { ArticleImagePickerDialog } from '../components/editor/ArticleImagePickerDialog';
import { Tooltip } from '../components/ui/Tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache'; import { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
@@ -33,6 +41,46 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
type MsgTab = 'dm' | 'notify'; type MsgTab = 'dm' | 'notify';
type ConvSnap = { conversations: MessageConversation[]; total: number; page: number }; type ConvSnap = { conversations: MessageConversation[]; total: number; page: number };
type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null }; type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null };
type PmDraftEmbed = { id: string; url: string; name: string };
type PmDraft = { text: string; embeds: PmDraftEmbed[] };
function newEmbedId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function serializePmDraft(text: string, embeds: PmDraftEmbed[]) {
const trimmed = text.trim();
const parts = [
...(trimmed ? [trimmed] : []),
...embeds.map((e) => `![${e.name}](${e.url})`),
];
return parts.join('\n');
}
function isDraftEmpty(text: string, embeds: PmDraftEmbed[]) {
return !text.trim() && embeds.length === 0;
}
/** 搜索/主页跳转用的对方用户摘要 → User */
function toPeerUser(u: { id: number; username: string; nickname: string; avatar?: string }): User {
return {
id: u.id,
username: u.username,
nickname: u.nickname,
avatar: u.avatar || '',
role: 'user',
};
}
function makePendingConversation(peer: User): MessageConversation {
return {
peer_user_id: peer.id,
peer_user: peer,
is_system: false,
unread_count: 0,
updated_at: new Date().toISOString(),
};
}
const NOTIFY_KINDS = [ const NOTIFY_KINDS = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
@@ -74,6 +122,30 @@ function isPendingModeration(m: PrivateMessage) {
return m.kind === 'moderation' && (!m.related_status || m.related_status === 'pending'); return m.kind === 'moderation' && (!m.related_status || m.related_status === 'pending');
} }
/** 私信气泡:纯文本 + 简易 markdown 图片 `![alt](url)` */
function PmBubbleContent({ content }: { content: string }) {
const parts = content.split(/(!\[[^\]]*]\([^)]+\))/g);
return (
<div className="pm-bubble__text">
{parts.map((part, i) => {
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
if (m) {
return (
<img
key={i}
className="pm-bubble__img"
src={m[2]}
alt={m[1] || '图片'}
loading="lazy"
/>
);
}
return part ? <span key={i}>{part}</span> : null;
})}
</div>
);
}
function KindIcon({ kind }: { kind: string }) { function KindIcon({ kind }: { kind: string }) {
const props = { size: 15, 'aria-hidden': true as const }; const props = { size: 15, 'aria-hidden': true as const };
switch (kind) { switch (kind) {
@@ -135,7 +207,12 @@ function peerInitial(name: string) {
function previewText(msg?: PrivateMessage) { function previewText(msg?: PrivateMessage) {
if (!msg) return '暂无消息'; if (!msg) return '暂无消息';
const text = (msg.content || msg.subject || '').replace(/\s+/g, ' ').trim(); const raw = (msg.content || msg.subject || '').trim();
if (!raw) return msg.subject || '暂无消息';
const text = raw
.replace(/!\[[^\]]*]\([^)]+\)/g, '[图片]')
.replace(/\s+/g, ' ')
.trim();
return text || msg.subject || '暂无消息'; return text || msg.subject || '暂无消息';
} }
@@ -143,22 +220,24 @@ function AvatarBubble({
name, name,
avatar, avatar,
system, system,
className,
}: { }: {
name: string; name: string;
avatar?: string; avatar?: string;
system?: boolean; system?: boolean;
className?: string;
}) { }) {
if (system) { if (system) {
return ( return (
<span className="pm-avatar pm-avatar--system" aria-hidden> <span className={cn('pm-avatar pm-avatar--system', className)} aria-hidden>
<Bell size={14} /> <Bell size={14} />
</span> </span>
); );
} }
if (avatar) { if (avatar) {
return <img src={avatar} alt="" className="pm-avatar" loading="lazy" decoding="async" />; return <img src={avatar} alt="" className={cn('pm-avatar', className)} loading="lazy" decoding="async" />;
} }
return <span className="pm-avatar pm-avatar--fallback">{peerInitial(name)}</span>; return <span className={cn('pm-avatar pm-avatar--fallback', className)}>{peerInitial(name)}</span>;
} }
function parseTab(raw: string | null, peer: string | null): MsgTab { function parseTab(raw: string | null, peer: string | null): MsgTab {
@@ -190,9 +269,24 @@ export default function MessagesPage() {
const [threadLoading, setThreadLoading] = useState(false); const [threadLoading, setThreadLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [peerUser, setPeerUser] = useState<User | null>(null); const [peerUser, setPeerUser] = useState<User | null>(null);
const [draft, setDraft] = useState(''); const [draftText, setDraftText] = useState('');
const [draftEmbeds, setDraftEmbeds] = useState<PmDraftEmbed[]>([]);
const [showSticker, setShowSticker] = useState(false);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const draftRef = useRef<HTMLTextAreaElement>(null);
const draftsByPeerRef = useRef<Map<number, PmDraft>>(new Map());
const draftPeerRef = useRef<number | null>(null);
const draftTextRef = useRef(draftText);
const draftEmbedsRef = useRef(draftEmbeds);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [convSearchQuery, setConvSearchQuery] = useState('');
const [convSearchResults, setConvSearchResults] = useState<User[]>([]);
const [convSearchLoading, setConvSearchLoading] = useState(false);
const [convSearchOpen, setConvSearchOpen] = useState(false);
const convSearchSeq = useRef(0);
const convSearchBlurTimer = useRef<number | null>(null);
const [notifications, setNotifications] = useState<PrivateMessage[]>([]); const [notifications, setNotifications] = useState<PrivateMessage[]>([]);
const [notifyTotal, setNotifyTotal] = useState(0); const [notifyTotal, setNotifyTotal] = useState(0);
const [notifyPage, setNotifyPage] = useState(1); const [notifyPage, setNotifyPage] = useState(1);
@@ -206,11 +300,161 @@ export default function MessagesPage() {
const stickToBottomRef = useRef(true); const stickToBottomRef = useRef(true);
const notifyLoadSeq = useRef(0); const notifyLoadSeq = useRef(0);
useEffect(() => {
draftTextRef.current = draftText;
}, [draftText]);
useEffect(() => {
draftEmbedsRef.current = draftEmbeds;
}, [draftEmbeds]);
const updateDraftText = useCallback((next: string | ((prev: string) => string)) => {
setDraftText((prev) => {
const value = typeof next === 'function' ? next(prev) : next;
draftTextRef.current = value;
return value;
});
}, []);
const updateDraftEmbeds = useCallback((next: PmDraftEmbed[] | ((prev: PmDraftEmbed[]) => PmDraftEmbed[])) => {
setDraftEmbeds((prev) => {
const value = typeof next === 'function' ? next(prev) : next;
draftEmbedsRef.current = value;
return value;
});
}, []);
const dmConversations = useMemo( const dmConversations = useMemo(
() => conversations.filter((c) => !c.is_system && c.peer_user_id > 0), () => conversations.filter((c) => !c.is_system && c.peer_user_id > 0),
[conversations], [conversations],
); );
/** 将会话加入左侧列表(无历史消息时也显示,类似飞书「发起会话」) */
const ensurePeerConversation = useCallback((peer: User) => {
setConversations((prev) => {
const idx = prev.findIndex((c) => c.peer_user_id === peer.id);
if (idx >= 0) {
const cur = prev[idx];
if (cur.peer_user) return prev;
const next = [...prev];
next[idx] = { ...cur, peer_user: peer };
return next;
}
return [makePendingConversation(peer), ...prev];
});
}, []);
const clearConvSearch = useCallback(() => {
setConvSearchQuery('');
setConvSearchResults([]);
setConvSearchOpen(false);
}, []);
const selectSearchUser = useCallback((peer: User) => {
ensurePeerConversation(peer);
clearConvSearch();
setShowSticker(false);
setImagePickerOpen(false);
const p = new URLSearchParams();
p.set('tab', 'dm');
p.set('peer', String(peer.id));
setParams(p, { replace: true });
}, [clearConvSearch, ensurePeerConversation, setParams]);
/** 切换会话:缓存当前草稿、恢复目标草稿,并关闭表情/图片面板 */
useEffect(() => {
setShowSticker(false);
setImagePickerOpen(false);
const prev = draftPeerRef.current;
const nextPeer = peerSelected && selectedPeer !== null ? selectedPeer : null;
if (prev != null && prev !== nextPeer) {
const text = draftTextRef.current;
const embeds = draftEmbedsRef.current;
if (isDraftEmpty(text, embeds)) {
draftsByPeerRef.current.delete(prev);
} else {
draftsByPeerRef.current.set(prev, { text, embeds });
}
}
draftPeerRef.current = nextPeer;
if (nextPeer != null) {
const saved = draftsByPeerRef.current.get(nextPeer);
const text = saved?.text ?? '';
const embeds = saved?.embeds ?? [];
draftTextRef.current = text;
draftEmbedsRef.current = embeds;
setDraftText(text);
setDraftEmbeds(embeds);
} else {
draftTextRef.current = '';
draftEmbedsRef.current = [];
setDraftText('');
setDraftEmbeds([]);
}
}, [peerSelected, selectedPeer]);
/** 搜索用户(发起新会话) */
useEffect(() => {
const q = convSearchQuery.trim();
if (!q) {
setConvSearchResults([]);
setConvSearchLoading(false);
return;
}
const seq = ++convSearchSeq.current;
setConvSearchLoading(true);
const timer = window.setTimeout(() => {
api.searchUsers(q, 8)
.then((r) => {
if (seq !== convSearchSeq.current) return;
const list = (r.users || [])
.filter((u) => u.id !== user?.id)
.map(toPeerUser);
setConvSearchResults(list);
})
.catch(() => {
if (seq !== convSearchSeq.current) return;
setConvSearchResults([]);
})
.finally(() => {
if (seq === convSearchSeq.current) setConvSearchLoading(false);
});
}, 280);
return () => window.clearTimeout(timer);
}, [convSearchQuery, user?.id]);
/** URL 指定 peer 但列表尚无该会话时,补入列表并预载用户信息 */
useEffect(() => {
if (!user || !peerSelected || selectedPeer === null || selectedPeer <= 0) return;
const hit = conversations.find((c) => c.peer_user_id === selectedPeer);
if (hit?.peer_user) {
setPeerUser((prev) => prev ?? hit.peer_user!);
return;
}
let cancelled = false;
api.userProfile(selectedPeer)
.then((r) => {
if (cancelled) return;
const peer = toPeerUser(r.user);
ensurePeerConversation(peer);
setPeerUser((prev) => prev ?? peer);
})
.catch(() => {
if (cancelled) return;
ensurePeerConversation({
id: selectedPeer,
username: `user${selectedPeer}`,
nickname: `用户 #${selectedPeer}`,
avatar: '',
role: 'user',
});
});
return () => { cancelled = true; };
}, [user, peerSelected, selectedPeer, conversations, ensurePeerConversation]);
const visibleNotifications = useMemo( const visibleNotifications = useMemo(
() => (unreadOnly ? notifications.filter((m) => !m.is_read) : notifications), () => (unreadOnly ? notifications.filter((m) => !m.is_read) : notifications),
[notifications, unreadOnly], [notifications, unreadOnly],
@@ -336,6 +580,7 @@ export default function MessagesPage() {
setMessages(cached.messages); setMessages(cached.messages);
setMsgTotal(cached.total); setMsgTotal(cached.total);
setPeerUser(cached.peerUser); setPeerUser(cached.peerUser);
if (cached.peerUser) ensurePeerConversation(cached.peerUser);
setThreadLoading(false); setThreadLoading(false);
return; return;
} }
@@ -350,6 +595,7 @@ export default function MessagesPage() {
setMessages(messages); setMessages(messages);
setMsgTotal(r.total || 0); setMsgTotal(r.total || 0);
setPeerUser(peer); setPeerUser(peer);
if (peer) ensurePeerConversation(peer);
setSessionSnapshot(`messages:thread:${selectedPeer}`, { setSessionSnapshot(`messages:thread:${selectedPeer}`, {
messages, messages,
total: r.total || 0, total: r.total || 0,
@@ -368,7 +614,7 @@ export default function MessagesPage() {
if (!cancelled) setThreadLoading(false); if (!cancelled) setThreadLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch]); }, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch, ensurePeerConversation]);
useEffect(() => { useEffect(() => {
if (!threadLoading && stickToBottomRef.current) { if (!threadLoading && stickToBottomRef.current) {
@@ -383,7 +629,6 @@ export default function MessagesPage() {
if (notifyKind !== 'all') p.set('kind', notifyKind); if (notifyKind !== 'all') p.set('kind', notifyKind);
} }
setParams(p, { replace: true }); setParams(p, { replace: true });
setDraft('');
}; };
const setKind = (kind: string) => { const setKind = (kind: string) => {
@@ -398,14 +643,12 @@ export default function MessagesPage() {
p.set('tab', 'dm'); p.set('tab', 'dm');
p.set('peer', String(peerId)); p.set('peer', String(peerId));
setParams(p, { replace: true }); setParams(p, { replace: true });
setDraft('');
}; };
const closeThread = () => { const closeThread = () => {
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set('tab', 'dm'); p.set('tab', 'dm');
setParams(p, { replace: true }); setParams(p, { replace: true });
setDraft('');
}; };
const markAll = async () => { const markAll = async () => {
@@ -471,9 +714,9 @@ export default function MessagesPage() {
const send = async () => { const send = async () => {
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return; if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
const content = draft.trim(); const content = serializePmDraft(draftText, draftEmbeds);
if (!content) { if (!content) {
notify.warning('请填写内容'); notify.warning('请填写内容或添加图片');
return; return;
} }
setSending(true); setSending(true);
@@ -492,7 +735,12 @@ export default function MessagesPage() {
return next; return next;
}); });
setMsgTotal((n) => n + 1); setMsgTotal((n) => n + 1);
setDraft(''); setDraftText('');
setDraftEmbeds([]);
draftTextRef.current = '';
draftEmbedsRef.current = [];
draftsByPeerRef.current.delete(selectedPeer);
setShowSticker(false);
setConversations((prev) => { setConversations((prev) => {
const rest = prev.filter((c) => c.peer_user_id !== selectedPeer); const rest = prev.filter((c) => c.peer_user_id !== selectedPeer);
const existing = prev.find((c) => c.peer_user_id === selectedPeer); const existing = prev.find((c) => c.peer_user_id === selectedPeer);
@@ -516,6 +764,47 @@ export default function MessagesPage() {
} }
}; };
const insertAtCursor = useCallback((text: string) => {
const el = draftRef.current;
if (!el) {
updateDraftText((d) => d + text);
return;
}
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
const next = el.value.slice(0, start) + text + el.value.slice(end);
updateDraftText(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + text.length;
el.setSelectionRange(pos, pos);
});
}, [updateDraftText]);
const onPickSticker = useCallback((sticker: Sticker) => {
if (sticker.type === 'text' && sticker.text) {
insertAtCursor(sticker.text);
} else if (sticker.url) {
updateDraftEmbeds((prev) => [
...prev,
{ id: newEmbedId(), url: sticker.url!, name: sticker.name || '表情' },
]);
}
setShowSticker(false);
}, [insertAtCursor, updateDraftEmbeds]);
const onInsertImages = useCallback((urls: string[]) => {
if (!urls.length) return;
updateDraftEmbeds((prev) => [
...prev,
...urls.map((url) => ({ id: newEmbedId(), url, name: '图片' })),
]);
}, [updateDraftEmbeds]);
const removeEmbed = useCallback((id: string) => {
updateDraftEmbeds((prev) => prev.filter((e) => e.id !== id));
}, [updateDraftEmbeds]);
if (authLoading || (tab === 'dm' && listLoading && conversations.length === 0 && !peerSelected)) { if (authLoading || (tab === 'dm' && listLoading && conversations.length === 0 && !peerSelected)) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>; return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
} }
@@ -546,7 +835,7 @@ export default function MessagesPage() {
)} )}
</div> </div>
<div className="pm-workspace content-surface"> <div className={cn('pm-workspace content-surface', tab === 'dm' && 'pm-workspace--im')}>
<div className="pm-tabs" role="tablist" aria-label="消息类型"> <div className="pm-tabs" role="tablist" aria-label="消息类型">
<button <button
type="button" type="button"
@@ -674,13 +963,80 @@ export default function MessagesPage() {
) : ( ) : (
<div className={cn('pm-layout', peerSelected && 'pm-layout--thread')}> <div className={cn('pm-layout', peerSelected && 'pm-layout--thread')}>
<aside className="pm-list" aria-label="会话列表"> <aside className="pm-list" aria-label="会话列表">
<div className="pm-list-search">
<Search size={16} className="pm-list-search__icon" aria-hidden />
<input
type="search"
className="pm-list-search__input"
value={convSearchQuery}
placeholder="搜索用户…"
aria-label="搜索用户发起私信"
aria-expanded={convSearchOpen}
aria-controls="pm-conv-search-results"
onChange={(e) => {
setConvSearchQuery(e.target.value);
setConvSearchOpen(true);
}}
onFocus={() => {
if (convSearchBlurTimer.current) {
window.clearTimeout(convSearchBlurTimer.current);
convSearchBlurTimer.current = null;
}
if (convSearchQuery.trim()) setConvSearchOpen(true);
}}
onBlur={() => {
convSearchBlurTimer.current = window.setTimeout(() => {
setConvSearchOpen(false);
}, 160);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') clearConvSearch();
if (e.key === 'Enter' && convSearchResults[0]) {
e.preventDefault();
selectSearchUser(convSearchResults[0]);
}
}}
/>
{convSearchOpen && convSearchQuery.trim() && (
<ul
id="pm-conv-search-results"
className="pm-list-search__dropdown"
role="listbox"
aria-label="搜索结果"
>
{convSearchLoading ? (
<li className="pm-list-search__empty">
<Spinner size="sm" />
<span></span>
</li>
) : convSearchResults.length === 0 ? (
<li className="pm-list-search__empty"></li>
) : (
convSearchResults.map((u) => (
<li key={u.id} role="option">
<button
type="button"
className="pm-list-search__item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSearchUser(u)}
>
<AvatarBubble name={u.nickname || u.username} avatar={u.avatar} className="pm-avatar--search" />
<span className="pm-list-search__name">{u.nickname || u.username}</span>
<span className="pm-list-search__username">@{u.username}</span>
</button>
</li>
))
)}
</ul>
)}
</div>
{listLoading && dmConversations.length === 0 ? ( {listLoading && dmConversations.length === 0 ? (
<div className="flex justify-center py-10"><Spinner /></div> <div className="flex justify-center py-10"><Spinner /></div>
) : dmConversations.length === 0 ? ( ) : dmConversations.length === 0 ? (
<div className="pm-empty"> <div className="pm-empty">
<Inbox size={28} strokeWidth={1.5} aria-hidden /> <Inbox size={28} strokeWidth={1.5} aria-hidden />
<p></p> <p></p>
<span></span> <span></span>
</div> </div>
) : ( ) : (
dmConversations.map((c) => { dmConversations.map((c) => {
@@ -697,9 +1053,11 @@ export default function MessagesPage() {
<div className="pm-conv-item__body"> <div className="pm-conv-item__body">
<div className="pm-conv-item__top"> <div className="pm-conv-item__top">
<span className="pm-conv-item__name">{name}</span> <span className="pm-conv-item__name">{name}</span>
<span className="pm-conv-item__time"> {c.last_message && (
{formatDateTime(c.last_message?.created_at || c.updated_at)} <span className="pm-conv-item__time">
</span> {formatConvListTime(c.last_message.created_at || c.updated_at)}
</span>
)}
</div> </div>
<div className="pm-conv-item__preview"> <div className="pm-conv-item__preview">
<span>{previewText(c.last_message)}</span> <span>{previewText(c.last_message)}</span>
@@ -746,9 +1104,9 @@ export default function MessagesPage() {
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar} avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
/> />
<div className="pm-thread-head__meta"> <div className="pm-thread-head__meta">
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link> <span className="pm-thread-head__name">{title}</span>
<span className="pm-thread-head__sub"></span>
</div> </div>
<Link to={userPath(selectedPeer)} className="pm-thread-head__profile"></Link>
</header> </header>
<div <div
@@ -775,17 +1133,35 @@ export default function MessagesPage() {
) : ( ) : (
messages.map((m) => { messages.map((m) => {
const mine = m.from_user_id === user.id; const mine = m.from_user_id === user.id;
const peerAvatarUser = m.from_user
|| peerUser
|| activeConv?.peer_user;
const avatarName = mine
? (user.nickname || user.username || '我')
: (peerAvatarUser?.nickname || peerAvatarUser?.username || title);
const avatarSrc = mine
? user.avatar
: peerAvatarUser?.avatar;
const avatar = (
<AvatarBubble
name={avatarName}
avatar={avatarSrc}
className="pm-avatar--bubble"
/>
);
return ( return (
<div <div
key={m.id} key={m.id}
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')} className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
> >
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}> {!mine && avatar}
<div className="pm-bubble__text">{m.content}</div> <div className="pm-bubble-stack">
<div className="pm-bubble__meta"> <div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
<time>{formatDateTime(m.created_at)}</time> <PmBubbleContent content={m.content} />
</div> </div>
<time className="pm-bubble__time">{formatDateTime(m.created_at)}</time>
</div> </div>
{mine && avatar}
</div> </div>
); );
}) })
@@ -798,10 +1174,11 @@ export default function MessagesPage() {
{canCompose && ( {canCompose && (
<footer className="pm-composer"> <footer className="pm-composer">
<textarea <textarea
ref={draftRef}
className="pm-composer__input" className="pm-composer__input"
value={draft} value={draftText}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => updateDraftText(e.target.value)}
rows={2} rows={3}
maxLength={4000} maxLength={4000}
placeholder={`发送给 ${title}`} placeholder={`发送给 ${title}`}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -811,15 +1188,80 @@ export default function MessagesPage() {
} }
}} }}
/> />
<Button {draftEmbeds.length > 0 && (
className="pm-composer__send" <ul className="pm-composer__embeds" aria-label="待发送图片">
loading={sending} {draftEmbeds.map((emb) => (
disabled={!draft.trim()} <li key={emb.id} className="pm-composer__embed">
onClick={() => void send()} <img src={emb.url} alt={emb.name} loading="lazy" />
> <button
<Send size={16} /> type="button"
className="pm-composer__embed-remove"
</Button> aria-label={`移除${emb.name}`}
onClick={() => removeEmbed(emb.id)}
>
<X size={12} aria-hidden />
</button>
</li>
))}
</ul>
)}
<div className="pm-composer__toolbar">
<div className="pm-composer__tools">
<Tooltip content="表情" side="top">
<button
type="button"
className={cn('pm-composer__tool', showSticker && 'active')}
aria-label="表情"
aria-pressed={showSticker}
onClick={() => {
setImagePickerOpen(false);
setShowSticker((v) => !v);
}}
>
<Smile size={18} aria-hidden />
</button>
</Tooltip>
<Tooltip content="图片" side="top">
<button
type="button"
className="pm-composer__tool"
aria-label="图片"
onClick={() => {
setShowSticker(false);
setImagePickerOpen(true);
}}
>
<ImagePlus size={18} aria-hidden />
</button>
</Tooltip>
</div>
<button
type="button"
className="pm-composer__send"
disabled={isDraftEmpty(draftText, draftEmbeds) || sending}
onClick={() => void send()}
>
{sending ? (
<Spinner size="sm" />
) : (
<>
<Send size={14} aria-hidden />
</>
)}
</button>
</div>
{showSticker && (
<div className="pm-composer__picker">
<StickerPicker onSelect={onPickSticker} />
</div>
)}
<p className="pm-composer__hint">Enter · Shift+Enter </p>
<ArticleImagePickerDialog
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
onInsert={onInsertImages}
/>
</footer> </footer>
)} )}
</> </>

View File

@@ -1,4 +1,7 @@
import { ExternalLink, Globe2 } from 'lucide-react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ExternalLink, Globe2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { api } from '../api/client'; import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types'; import type { CommunityShowcaseItem } from '../api/types';
@@ -7,12 +10,65 @@ import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding
import { InFlowSiteFooter } from '../components/SiteFooter'; import { InFlowSiteFooter } from '../components/SiteFooter';
import { useSessionResource } from '../hooks/useSessionResource'; import { useSessionResource } from '../hooks/useSessionResource';
function siteMark(name: string, url: string): string {
const raw = (name || '').trim() || url.replace(/^https?:\/\//i, '');
const ch = Array.from(raw)[0];
return ch ? ch.toUpperCase() : '?';
}
function hostLabel(url: string): string {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//i, '');
}
}
/** 目标站约定路径 /favicon.ico */
function faviconURL(siteURL: string): string | null {
try {
return new URL('/favicon.ico', siteURL).href;
} catch {
return null;
}
}
function ShowcaseFavicon({ name, url }: { name: string; url: string }) {
const src = faviconURL(url);
const [failed, setFailed] = useState(!src);
const mark = siteMark(name, url);
if (failed || !src) {
return (
<span className="showcase-item-mark" aria-hidden>
{mark}
</span>
);
}
return (
<span className="showcase-item-favicon">
<img
src={src}
alt=""
width={40}
height={40}
referrerPolicy="no-referrer"
decoding="async"
onError={() => setFailed(true)}
/>
</span>
);
}
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */ /** 官方精选的公网部署展柜(只读;仅人工精选条目) */
export default function ShowcasePage() { export default function ShowcasePage() {
const nav = useNavigate();
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>( const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>(
'showcase', 'showcase',
() => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])), () => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
{ revalidateEmpty: true },
); );
usePageSEO({ usePageSEO({
@@ -24,52 +80,65 @@ export default function ShowcasePage() {
return ( return (
<div className="page-wrap"> <div className="page-wrap">
<div className="showcase-page"> <div className="feed-panel list-page-panel showcase-panel">
<header className="showcase-head"> <header className="list-page-panel__head showcase-head">
<div className="showcase-head-mark" aria-hidden> <Button variant="ghost" size="sm" className="list-page-panel__back" onClick={() => nav('/')}>
<Globe2 size={22} /> <ArrowLeft />
</div>
<div> </Button>
<h1 className="showcase-title"></h1> <div className="showcase-head__title-row">
<p className="showcase-desc"> <h1 className="page-title"></h1>
{!loading && items.length > 0 && (
<span className="showcase-head__count"> {items.length} </span>
)}
</div>
<p className="page-desc">
</p> </p>
</div> </header>
</header>
{loading ? ( {loading ? (
<div className="flex justify-center py-16"><Spinner size="lg" /></div> <div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="showcase-empty"></p> <div className="showcase-empty list-page-panel__empty">
) : ( <Globe2 size={28} strokeWidth={1.5} aria-hidden />
<ul className="showcase-list"> <p></p>
{items.map((item) => ( <span></span>
<li key={item.site_url} className="showcase-item"> </div>
<a ) : (
href={item.site_url} <ul className="showcase-list">
target="_blank" {items.map((item) => {
rel="noopener noreferrer" const name = item.site_name || '未命名站点';
className="showcase-item-link" return (
> <li key={item.site_url} className="showcase-item">
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span> <a
<span className="showcase-item-url"> href={item.site_url}
{item.site_url} target="_blank"
<ExternalLink size={12} aria-hidden /> rel="noopener noreferrer"
</span> className="showcase-item-link"
{(item.featured_note || item.version) && ( >
<span className="showcase-item-meta"> <ShowcaseFavicon name={name} url={item.site_url} />
{item.featured_note || null} <span className="showcase-item-body">
{item.featured_note && item.version ? ' · ' : null} <span className="showcase-item-name">{name}</span>
{item.version ? `v${item.version}` : null} <span className="showcase-item-url">
</span> {hostLabel(item.site_url)}
)} <ExternalLink size={12} aria-hidden />
</a> </span>
</li> {item.featured_note ? (
))} <span className="showcase-item-note">{item.featured_note}</span>
</ul> ) : null}
)} </span>
{item.version ? (
<span className="showcase-item-version">v{item.version}</span>
) : null}
</a>
</li>
);
})}
</ul>
)}
<InFlowSiteFooter /> <InFlowSiteFooter />
</div> </div>
</div> </div>
); );

View File

@@ -23,7 +23,6 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { useSessionResource } from '../hooks/useSessionResource'; import { useSessionResource } from '../hooks/useSessionResource';
import PostListItem from '../components/PostListItem'; import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination'; import FeedPagination from '../components/FeedPagination';
import ComposeMessageDialog from '../components/ComposeMessageDialog';
import { openForumPost } from '../utils/openPost'; import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content'; import { formatDateTime } from '../utils/content';
import { usePageSEO } from '../hooks/usePageSEO'; import { usePageSEO } from '../hooks/usePageSEO';
@@ -44,7 +43,6 @@ export default function UserProfilePage() {
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20; const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [msgOpen, setMsgOpen] = useState(false);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [postPage, setPostPage] = useState(1); const [postPage, setPostPage] = useState(1);
@@ -183,7 +181,7 @@ export default function UserProfilePage() {
nav(loginPath(userPath(profile.id))); nav(loginPath(userPath(profile.id)));
return; return;
} }
setMsgOpen(true); nav(`/messages?peer=${profile.id}`);
}} }}
> >
<Mail size={14} /> <Mail size={14} />
@@ -265,16 +263,6 @@ export default function UserProfilePage() {
</div> </div>
</div> </div>
<InFlowSiteFooter /> <InFlowSiteFooter />
{!isSelf && profile && me && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={profile.id}
toNickname={profile.nickname}
onSent={() => nav(`/messages?peer=${profile.id}`)}
/>
)}
</div> </div>
); );
} }

View File

@@ -12799,6 +12799,32 @@ button.profile-stat:hover strong {
padding: 0; padding: 0;
} }
.pm-workspace--im.content-surface {
background: color-mix(in srgb, #faf9f7 35%, var(--j13-bg-surface));
border: 1px solid var(--j13-border-light);
border-radius: 12px;
box-shadow: 0 1px 3px color-mix(in srgb, var(--j13-muted, #94a3b8) 8%, transparent);
}
.pm-workspace--im .pm-tabs {
gap: 6px;
padding: 10px 14px;
background: transparent;
border-bottom: 1px solid var(--j13-border-light);
}
.pm-workspace--im .pm-tab {
margin-bottom: 0;
padding: 6px 14px;
border-bottom: none;
border-radius: 999px;
}
.pm-workspace--im .pm-tab.active {
background: color-mix(in srgb, var(--j13-green) 12%, transparent);
border-bottom-color: transparent;
}
.pm-tabs { .pm-tabs {
display: flex; display: flex;
gap: 0; gap: 0;
@@ -13080,20 +13106,140 @@ button.profile-stat:hover strong {
.pm-layout { .pm-layout {
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 280px 1fr;
height: min(70vh, 640px); height: min(72vh, 680px);
min-height: 460px; min-height: 480px;
overflow: hidden; overflow: hidden;
} }
.pm-list { .pm-list {
border-right: 1px solid var(--j13-border-light);
overflow-y: auto; overflow-y: auto;
background: var(--j13-bg-block-muted); padding: 6px 8px;
background: color-mix(in srgb, #faf9f7 28%, var(--j13-bg-surface));
border-right: 1px solid var(--j13-border-light);
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--j13-muted, #94a3b8) 35%, transparent) transparent;
}
.pm-list-search {
position: relative;
padding: 8px 6px 10px;
flex-shrink: 0;
}
.pm-list-search__icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(calc(-50% + 2px));
color: var(--color-text-4);
pointer-events: none;
}
.pm-list-search__input {
display: block;
width: 100%;
box-sizing: border-box;
height: 34px;
padding: 0 10px 0 34px;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
font: inherit;
font-size: 13px;
color: var(--color-text-1);
background: var(--j13-bg-surface);
outline: none;
}
.pm-list-search__input:focus {
border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border-light));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--j13-green) 8%, transparent);
}
.pm-list-search__input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
.pm-list-search__dropdown {
position: absolute;
left: 6px;
right: 6px;
top: calc(100% - 2px);
z-index: 20;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
background: var(--j13-bg-surface);
box-shadow: 0 8px 24px color-mix(in srgb, var(--j13-muted, #94a3b8) 18%, transparent);
max-height: 280px;
overflow-y: auto;
}
.pm-list-search__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
}
.pm-list-search__item:hover {
background: color-mix(in srgb, var(--j13-green) 8%, transparent);
}
.pm-list-search__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.pm-list-search__username {
flex-shrink: 0;
font-size: 11px;
color: var(--color-text-4);
}
.pm-list-search__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 10px;
font-size: 12px;
color: var(--color-text-3);
}
.pm-avatar--search {
width: 32px;
height: 32px;
font-size: 12px;
}
.pm-list::-webkit-scrollbar {
width: 6px;
}
.pm-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 35%, transparent);
border-radius: 999px;
} }
.pm-avatar { .pm-avatar {
width: 36px; width: 44px;
height: 36px; height: 44px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
@@ -13105,7 +13251,7 @@ button.profile-stat:hover strong {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--j13-green); color: var(--j13-green);
background: var(--j13-green-bg); background: var(--j13-green-bg);
@@ -13116,14 +13262,20 @@ button.profile-stat:hover strong {
background: color-mix(in srgb, var(--j13-border) 35%, var(--j13-bg-surface)); background: color-mix(in srgb, var(--j13-border) 35%, var(--j13-bg-surface));
} }
.pm-avatar--bubble {
width: 36px;
height: 36px;
font-size: 13px;
}
.pm-conv-item { .pm-conv-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
padding: 11px 12px; padding: 10px 12px;
border: none; border: none;
border-bottom: 1px solid var(--j13-border-light); border-radius: 8px;
background: transparent; background: transparent;
text-align: left; text-align: left;
font: inherit; font: inherit;
@@ -13133,16 +13285,16 @@ button.profile-stat:hover strong {
} }
.pm-conv-item:hover { .pm-conv-item:hover {
background: color-mix(in srgb, var(--j13-bg-surface) 75%, transparent); background: color-mix(in srgb, var(--j13-muted, #94a3b8) 6%, transparent);
} }
.pm-conv-item.active { .pm-conv-item.active {
background: var(--j13-bg-surface); background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface));
box-shadow: inset 2px 0 0 var(--j13-green);
} }
.pm-conv-item.unread .pm-conv-item__name { .pm-conv-item.unread .pm-conv-item__name {
font-weight: 700; font-weight: 650;
color: var(--color-text-1);
} }
.pm-conv-item__body { .pm-conv-item__body {
@@ -13153,27 +13305,25 @@ button.profile-stat:hover strong {
.pm-conv-item__top { .pm-conv-item__top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 2px; margin-bottom: 4px;
} }
.pm-conv-item__name { .pm-conv-item__name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13.5px; font-size: 14px;
font-weight: 600; font-weight: 550;
color: var(--color-text-1); color: var(--color-text-1);
} }
.pm-conv-item__time { .pm-conv-item__time {
flex-shrink: 0; flex-shrink: 0;
font-size: 10.5px; font-size: 11px;
color: var(--color-text-4); color: var(--color-text-4);
text-align: right; line-height: 1.2;
max-width: 7.5rem;
line-height: 1.25;
} }
.pm-conv-item__preview { .pm-conv-item__preview {
@@ -13181,7 +13331,8 @@ button.profile-stat:hover strong {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-4);
line-height: 1.35;
} }
.pm-conv-item__preview > span:first-child { .pm-conv-item__preview > span:first-child {
@@ -13195,18 +13346,18 @@ button.profile-stat:hover strong {
flex-shrink: 0; flex-shrink: 0;
min-width: 18px; min-width: 18px;
height: 18px; height: 18px;
padding: 0 5px; padding: 0 6px;
border-radius: 999px; border-radius: 999px;
background: hsl(var(--destructive)); background: color-mix(in srgb, var(--j13-green) 16%, var(--j13-bg-surface));
color: #fff; color: var(--j13-green);
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 600;
line-height: 18px; line-height: 18px;
text-align: center; text-align: center;
} }
.pm-list-more { .pm-list-more {
padding: 8px; padding: 10px;
text-align: center; text-align: center;
} }
@@ -13215,16 +13366,17 @@ button.profile-stat:hover strong {
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
background: var(--j13-bg-surface); background: var(--j13-bg-workspace);
} }
.pm-thread-head { .pm-thread-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 11px 14px; padding: 12px 16px;
border-bottom: 1px solid var(--j13-border-light); border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0; flex-shrink: 0;
background: var(--j13-bg-surface);
} }
.pm-thread-back { .pm-thread-back {
@@ -13233,7 +13385,7 @@ button.profile-stat:hover strong {
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
margin-right: -2px; margin-right: -4px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
@@ -13242,25 +13394,35 @@ button.profile-stat:hover strong {
} }
.pm-thread-back:hover { .pm-thread-back:hover {
background: var(--j13-bg-block-muted); background: color-mix(in srgb, var(--j13-muted, #94a3b8) 12%, transparent);
} }
.pm-thread-head__meta { .pm-thread-head__meta {
min-width: 0; min-width: 0;
display: flex; flex: 1;
flex-direction: column;
gap: 2px;
} }
.pm-thread-head__name { .pm-thread-head__name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 700;
color: var(--color-text-1); color: var(--color-text-1);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.pm-thread-head__profile {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--j13-green);
text-decoration: none;
}
.pm-thread-head__profile:hover {
text-decoration: underline;
}
a.pm-thread-head__name:hover { a.pm-thread-head__name:hover {
color: var(--j13-green); color: var(--j13-green);
} }
@@ -13274,38 +13436,64 @@ a.pm-thread-head__name:hover {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 16px 16px; padding: 16px 20px 20px;
background: var(--j13-bg-workspace); background: var(--j13-bg-workspace);
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--j13-muted, #94a3b8) 35%, transparent) transparent;
}
.pm-thread-scroll::-webkit-scrollbar {
width: 6px;
}
.pm-thread-scroll::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 35%, transparent);
border-radius: 999px;
} }
.pm-thread-older { .pm-thread-older {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 12px; margin-bottom: 14px;
} }
.pm-bubble-row { .pm-bubble-row {
display: flex; display: flex;
align-items: flex-start;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 10px; gap: 10px;
margin-bottom: 14px;
} }
.pm-bubble-row--mine { .pm-bubble-row--mine {
justify-content: flex-end; justify-content: flex-end;
} }
.pm-bubble-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
max-width: min(68%, 500px);
min-width: 0;
}
.pm-bubble-row--mine .pm-bubble-stack {
align-items: flex-end;
}
.pm-bubble { .pm-bubble {
max-width: min(78%, 480px); width: 100%;
padding: 9px 12px; padding: 10px 14px;
border-radius: 12px 12px 12px 4px; border-radius: 12px;
background: var(--j13-bg-block-muted); background: var(--j13-bg-surface);
border: 1px solid transparent; border: 1px solid var(--j13-border-light);
box-shadow: none;
} }
.pm-bubble--mine { .pm-bubble--mine {
border-radius: 12px 12px 4px 12px; background: color-mix(in srgb, var(--j13-green) 14%, var(--j13-bg-surface));
background: var(--j13-green-soft); border-color: color-mix(in srgb, var(--j13-green) 22%, transparent);
border-color: color-mix(in srgb, var(--j13-green) 18%, transparent);
} }
.pm-bubble__text { .pm-bubble__text {
@@ -13316,27 +13504,34 @@ a.pm-thread-head__name:hover {
color: var(--color-text-1); color: var(--color-text-1);
} }
.pm-bubble__meta { .pm-bubble__img {
display: flex; display: block;
align-items: center; max-width: min(220px, 100%);
gap: 8px; max-height: 220px;
margin-top: 5px; margin: 4px 0;
font-size: 11px; border-radius: 8px;
color: var(--color-text-4); object-fit: contain;
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 8%, transparent);
} }
.pm-bubble-row--mine .pm-bubble__meta { .pm-bubble--mine .pm-bubble__text {
justify-content: flex-end; color: color-mix(in srgb, var(--j13-green) 72%, #0f172a);
}
.pm-bubble__time {
align-self: center;
width: 100%;
font-size: 11px;
color: var(--color-text-4);
line-height: 1.2;
text-align: center;
} }
.pm-composer { .pm-composer {
display: flex; flex-shrink: 0;
align-items: flex-end; padding: 12px 16px 14px;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--j13-border-light); border-top: 1px solid var(--j13-border-light);
background: var(--j13-bg-surface); background: var(--j13-bg-surface);
flex-shrink: 0;
} }
.pm-composer--readonly { .pm-composer--readonly {
@@ -13348,28 +13543,157 @@ a.pm-thread-head__name:hover {
} }
.pm-composer__input { .pm-composer__input {
flex: 1; display: block;
min-height: 44px; width: 100%;
max-height: 140px; box-sizing: border-box;
resize: none; min-height: 72px;
border: 1px solid var(--j13-border); max-height: 160px;
resize: vertical;
border: 1px solid var(--j13-border-light);
border-radius: 10px; border-radius: 10px;
padding: 10px 12px; padding: 10px 14px;
font: inherit; font: inherit;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: var(--color-text-1); color: var(--color-text-1);
background: var(--j13-bg-block-muted); background: var(--j13-bg-workspace);
outline: none; outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
} }
.pm-composer__input:focus { .pm-composer__input:focus {
border-color: var(--j13-green); border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border-light));
background: var(--j13-bg-surface); box-shadow: 0 0 0 2px color-mix(in srgb, var(--j13-green) 8%, transparent);
}
.pm-composer__embeds {
display: flex;
flex-wrap: wrap;
gap: 8px;
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.pm-composer__embed {
position: relative;
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--j13-border-light);
background: var(--j13-bg-workspace);
}
.pm-composer__embed img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.pm-composer__embed-remove {
position: absolute;
top: 2px;
right: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: color-mix(in srgb, #0f172a 72%, transparent);
color: #fff;
cursor: pointer;
}
.pm-composer__embed-remove:hover {
background: #0f172a;
}
.pm-composer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.pm-composer__tools {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.pm-composer__tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.pm-composer__tool:hover:not(:disabled),
.pm-composer__tool.active {
background: color-mix(in srgb, var(--j13-green) 10%, transparent);
color: var(--j13-green);
}
.pm-composer__tool:disabled {
opacity: 0.45;
cursor: not-allowed;
} }
.pm-composer__send { .pm-composer__send {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 16px;
border: none;
border-radius: 10px;
background: var(--j13-green);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.12s;
}
.pm-composer__send:hover:not(:disabled) {
opacity: 0.92;
}
.pm-composer__send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pm-composer__picker {
margin-top: 8px;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
overflow: hidden;
background: var(--j13-bg-surface);
}
.pm-composer__hint {
margin: 6px 2px 0;
font-size: 11px;
color: var(--color-text-4);
text-align: right;
} }
.pm-empty { .pm-empty {
@@ -13377,8 +13701,8 @@ a.pm-thread-head__name:hover {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 8px;
padding: 48px 20px; padding: 56px 24px;
text-align: center; text-align: center;
color: var(--color-text-3); color: var(--color-text-3);
font-size: 13px; font-size: 13px;
@@ -13457,14 +13781,6 @@ a.pm-thread-head__name:hover {
.pm-thread-back { .pm-thread-back {
display: inline-flex; display: inline-flex;
} }
.pm-composer {
flex-wrap: wrap;
}
.pm-composer__send {
width: 100%;
}
} }
/* —— 用户徽章 / 等级 —— */ /* —— 用户徽章 / 等级 —— */
@@ -14946,87 +15262,135 @@ button.post-poll__option,
word-break: break-all; word-break: break-all;
} }
.showcase-page { .showcase-panel {
max-width: 720px; min-height: 0;
margin: 0 auto;
padding: 28px 16px 48px;
} }
.showcase-head { .showcase-head {
display: flex; border-bottom: 1px solid var(--j13-border-light);
gap: 14px; padding-bottom: 1rem;
align-items: flex-start; margin-bottom: 0;
margin-bottom: 24px;
} }
.showcase-head-mark { .showcase-head__title-row {
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: center; flex-wrap: wrap;
width: 40px; gap: 0.5rem 0.85rem;
height: 40px;
border-radius: 10px;
color: var(--j13-green);
background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface));
border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--j13-border-light));
flex-shrink: 0;
} }
.showcase-title { .showcase-head__title-row .page-title {
margin: 0; margin: 0;
font-size: 1.35rem;
font-weight: 650;
color: var(--color-text-1);
} }
.showcase-desc { .showcase-head__count {
margin: 6px 0 0; font-size: 0.8rem;
font-size: 13px; font-weight: 600;
line-height: 1.5; color: var(--color-text-3);
color: var(--color-text-2); letter-spacing: 0.02em;
}
.showcase-head .page-desc {
margin-top: 0.45rem;
max-width: none;
} }
.showcase-empty { .showcase-empty {
margin: 0; display: flex;
padding: 32px 0; flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 64px 20px;
text-align: center; text-align: center;
color: var(--color-text-3);
font-size: 13px;
}
.showcase-empty p {
margin: 8px 0 0;
font-size: 14px; font-size: 14px;
font-weight: 600;
color: var(--color-text-2); color: var(--color-text-2);
} }
.showcase-empty span {
font-size: 12px;
}
.showcase-list { .showcase-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
} }
.showcase-item { .showcase-item {
border: 1px solid var(--j13-border-light); border-bottom: 1px solid var(--j13-border-light);
border-radius: 10px; }
background: var(--j13-bg-surface);
.showcase-item:last-child {
border-bottom: 0;
} }
.showcase-item-link { .showcase-item-link {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; gap: 1rem;
padding: 14px 16px; min-height: 5.25rem;
padding: 1.15rem 0.35rem 1.15rem 0.75rem;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border-radius: 10px; border-left: 2px solid transparent;
transition: background 0.15s ease; transition: background 0.12s ease, border-color 0.12s ease;
} }
.showcase-item-link:hover { .showcase-item-link:hover {
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface)); background: color-mix(in srgb, var(--j13-green) 4%, transparent);
border-left-color: var(--j13-green);
}
.showcase-item-favicon,
.showcase-item-mark {
flex-shrink: 0;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.35rem;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--j13-border-light);
background: var(--j13-bg-surface, #fff);
}
.showcase-item-favicon img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.showcase-item-mark {
font-size: 1rem;
font-weight: 700;
color: var(--j13-green);
background: color-mix(in srgb, var(--j13-green) 8%, var(--j13-bg-surface, transparent));
}
.showcase-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.28rem;
} }
.showcase-item-name { .showcase-item-name {
font-size: 15px; font-size: 1.05rem;
font-weight: 600; font-weight: 700;
line-height: 1.35;
color: var(--color-text-1); color: var(--color-text-1);
} }
@@ -15034,14 +15398,43 @@ button.post-poll__option,
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 12px; font-size: 0.82rem;
color: var(--j13-green); color: var(--j13-green);
word-break: break-all; word-break: break-all;
} }
.showcase-item-meta { .showcase-item-note {
font-size: 12px; font-size: 0.8rem;
line-height: 1.45;
color: var(--color-text-3);
}
.showcase-item-version {
flex-shrink: 0;
align-self: center;
margin-left: auto;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
font-weight: 650;
letter-spacing: 0.02em;
color: var(--color-text-2); color: var(--color-text-2);
border: 1px solid var(--j13-border-light);
border-radius: 0.25rem;
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 8%, transparent);
white-space: nowrap;
}
@media (max-width: 560px) {
.showcase-item-link {
align-items: flex-start;
min-height: 0;
padding: 1rem 0.25rem 1rem 0.55rem;
}
.showcase-item-version {
align-self: flex-start;
margin-top: 0.15rem;
}
} }
.admin-links-logo-thumb { .admin-links-logo-thumb {

View File

@@ -89,6 +89,25 @@ export function formatTime(iso: string) {
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`; return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
} }
/** 会话列表时间:今天 HH:mm同年 M月D日更早含年 */
export function formatConvListTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
if (
d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */ /** 完整日期时间(用于帖子发布/修改时间展示) */
export function formatDateTime(iso: string) { export function formatDateTime(iso: string) {
const d = new Date(iso); const d = new Date(iso);

View File

@@ -44,7 +44,7 @@ export type HomeBootPayload = {
recent_comments: RecentComment[]; recent_comments: RecentComment[];
recent_users: RecentUser[]; recent_users: RecentUser[];
tags: TagCount[]; tags: TagCount[];
showcase: CommunityShowcaseItem[]; showcase?: CommunityShowcaseItem[];
pages: SitePageSummary[]; pages: SitePageSummary[];
limits: ForumLimitsPublic; limits: ForumLimitsPublic;
branding: SiteBranding; branding: SiteBranding;
@@ -77,7 +77,10 @@ export function consumeHomeBoot(): HomeBootPayload | null {
if (Array.isArray(boot.recent_comments)) setCachedRecentComments(boot.recent_comments); if (Array.isArray(boot.recent_comments)) setCachedRecentComments(boot.recent_comments);
if (Array.isArray(boot.recent_users)) setCachedRecentUsers(boot.recent_users); if (Array.isArray(boot.recent_users)) setCachedRecentUsers(boot.recent_users);
if (Array.isArray(boot.tags)) setCachedTags(boot.tags); if (Array.isArray(boot.tags)) setCachedTags(boot.tags);
if (Array.isArray(boot.showcase)) setSessionSnapshot('showcase', boot.showcase); // 仅当 boot 显式带 showcase 时灌入(侧栏关闭时省略,避免 [] 粘死)
if ('showcase' in boot && Array.isArray(boot.showcase)) {
setSessionSnapshot('showcase', boot.showcase);
}
// 鉴权 / 签到:有 user 字段即种子(含 null = 已确认访客) // 鉴权 / 签到:有 user 字段即种子(含 null = 已确认访客)
if ('user' in boot) { if ('user' in boot) {

View File

@@ -36,7 +36,8 @@ type homeBootPayload struct {
RecentComments []service.RecentCommentItem `json:"recent_comments"` RecentComments []service.RecentCommentItem `json:"recent_comments"`
RecentUsers []service.RecentUserItem `json:"recent_users"` RecentUsers []service.RecentUserItem `json:"recent_users"`
Tags []service.TagCount `json:"tags"` Tags []service.TagCount `json:"tags"`
Showcase []service.CommunityShowcaseItem `json:"showcase"` // omitempty侧栏关闭或未拉取时不写字段避免 SPA 把 [] 当成有效会话快照
Showcase []service.CommunityShowcaseItem `json:"showcase,omitempty"`
Pages []service.SitePageSummary `json:"pages"` Pages []service.SitePageSummary `json:"pages"`
Limits service.ForumLimitsPublic `json:"limits"` Limits service.ForumLimitsPublic `json:"limits"`
Branding service.SiteBranding `json:"branding"` Branding service.SiteBranding `json:"branding"`
@@ -261,13 +262,15 @@ func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeSSRData, er
go func() { go func() {
defer wg.Done() defer wg.Done()
items, err := h.Community.ListShowcase(c.Request.Host) items, err := h.Community.ListShowcase(c.Request.Host)
if err != nil || items == nil { if err != nil {
// 失败不写 showcase让前端自行请求避免假空粘死
return
}
if items == nil {
items = []service.CommunityShowcaseItem{} items = []service.CommunityShowcaseItem{}
} }
out.boot.Showcase = items out.boot.Showcase = items
}() }()
} else {
out.boot.Showcase = []service.CommunityShowcaseItem{}
} }
uid := h.currentUserID(c) uid := h.currentUserID(c)