feat: 重设计私信页并优化开源展柜体验
私信改为飞书风布局,支持缩略图表情/图片、按会话草稿与用户搜索发起会话;主页发私信直达消息页。开源展柜修复空列表粘滞并改版目录式 UI。
This commit is contained in:
@@ -6,11 +6,15 @@ import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Flag,
|
||||
ImagePlus,
|
||||
Inbox,
|
||||
Mail,
|
||||
MessageCircleReply,
|
||||
Search,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Smile,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,10 +25,14 @@ import type { MessageConversation, PrivateMessage, User } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { formatDateTime, formatTime } from '../utils/content';
|
||||
import { formatConvListTime, formatDateTime, formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { userPath } from '../utils/userPath';
|
||||
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 { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache';
|
||||
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 ConvSnap = { conversations: MessageConversation[]; total: number; page: number };
|
||||
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) => ``),
|
||||
];
|
||||
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 = [
|
||||
{ key: 'all', label: '全部' },
|
||||
@@ -74,6 +122,30 @@ function isPendingModeration(m: PrivateMessage) {
|
||||
return m.kind === 'moderation' && (!m.related_status || m.related_status === 'pending');
|
||||
}
|
||||
|
||||
/** 私信气泡:纯文本 + 简易 markdown 图片 `` */
|
||||
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 }) {
|
||||
const props = { size: 15, 'aria-hidden': true as const };
|
||||
switch (kind) {
|
||||
@@ -135,7 +207,12 @@ function peerInitial(name: string) {
|
||||
|
||||
function previewText(msg?: PrivateMessage) {
|
||||
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 || '暂无消息';
|
||||
}
|
||||
|
||||
@@ -143,22 +220,24 @@ function AvatarBubble({
|
||||
name,
|
||||
avatar,
|
||||
system,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
system?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (system) {
|
||||
return (
|
||||
<span className="pm-avatar pm-avatar--system" aria-hidden>
|
||||
<span className={cn('pm-avatar pm-avatar--system', className)} aria-hidden>
|
||||
<Bell size={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 {
|
||||
@@ -190,9 +269,24 @@ export default function MessagesPage() {
|
||||
const [threadLoading, setThreadLoading] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
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 [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 [notifyTotal, setNotifyTotal] = useState(0);
|
||||
const [notifyPage, setNotifyPage] = useState(1);
|
||||
@@ -206,11 +300,161 @@ export default function MessagesPage() {
|
||||
const stickToBottomRef = useRef(true);
|
||||
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(
|
||||
() => conversations.filter((c) => !c.is_system && c.peer_user_id > 0),
|
||||
[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(
|
||||
() => (unreadOnly ? notifications.filter((m) => !m.is_read) : notifications),
|
||||
[notifications, unreadOnly],
|
||||
@@ -336,6 +580,7 @@ export default function MessagesPage() {
|
||||
setMessages(cached.messages);
|
||||
setMsgTotal(cached.total);
|
||||
setPeerUser(cached.peerUser);
|
||||
if (cached.peerUser) ensurePeerConversation(cached.peerUser);
|
||||
setThreadLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -350,6 +595,7 @@ export default function MessagesPage() {
|
||||
setMessages(messages);
|
||||
setMsgTotal(r.total || 0);
|
||||
setPeerUser(peer);
|
||||
if (peer) ensurePeerConversation(peer);
|
||||
setSessionSnapshot(`messages:thread:${selectedPeer}`, {
|
||||
messages,
|
||||
total: r.total || 0,
|
||||
@@ -368,7 +614,7 @@ export default function MessagesPage() {
|
||||
if (!cancelled) setThreadLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch]);
|
||||
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch, ensurePeerConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadLoading && stickToBottomRef.current) {
|
||||
@@ -383,7 +629,6 @@ export default function MessagesPage() {
|
||||
if (notifyKind !== 'all') p.set('kind', notifyKind);
|
||||
}
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const setKind = (kind: string) => {
|
||||
@@ -398,14 +643,12 @@ export default function MessagesPage() {
|
||||
p.set('tab', 'dm');
|
||||
p.set('peer', String(peerId));
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const closeThread = () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('tab', 'dm');
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const markAll = async () => {
|
||||
@@ -471,9 +714,9 @@ export default function MessagesPage() {
|
||||
|
||||
const send = async () => {
|
||||
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
||||
const content = draft.trim();
|
||||
const content = serializePmDraft(draftText, draftEmbeds);
|
||||
if (!content) {
|
||||
notify.warning('请填写内容');
|
||||
notify.warning('请填写内容或添加图片');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
@@ -492,7 +735,12 @@ export default function MessagesPage() {
|
||||
return next;
|
||||
});
|
||||
setMsgTotal((n) => n + 1);
|
||||
setDraft('');
|
||||
setDraftText('');
|
||||
setDraftEmbeds([]);
|
||||
draftTextRef.current = '';
|
||||
draftEmbedsRef.current = [];
|
||||
draftsByPeerRef.current.delete(selectedPeer);
|
||||
setShowSticker(false);
|
||||
setConversations((prev) => {
|
||||
const rest = prev.filter((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)) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
@@ -546,7 +835,7 @@ export default function MessagesPage() {
|
||||
)}
|
||||
</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="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
@@ -674,13 +963,80 @@ export default function MessagesPage() {
|
||||
) : (
|
||||
<div className={cn('pm-layout', peerSelected && 'pm-layout--thread')}>
|
||||
<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 ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : dmConversations.length === 0 ? (
|
||||
<div className="pm-empty">
|
||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>还没有私信</p>
|
||||
<span>在用户主页点击「发私信」开始对话</span>
|
||||
<span>搜索用户或在用户主页点击「发私信」</span>
|
||||
</div>
|
||||
) : (
|
||||
dmConversations.map((c) => {
|
||||
@@ -697,9 +1053,11 @@ export default function MessagesPage() {
|
||||
<div className="pm-conv-item__body">
|
||||
<div className="pm-conv-item__top">
|
||||
<span className="pm-conv-item__name">{name}</span>
|
||||
<span className="pm-conv-item__time">
|
||||
{formatDateTime(c.last_message?.created_at || c.updated_at)}
|
||||
</span>
|
||||
{c.last_message && (
|
||||
<span className="pm-conv-item__time">
|
||||
{formatConvListTime(c.last_message.created_at || c.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pm-conv-item__preview">
|
||||
<span>{previewText(c.last_message)}</span>
|
||||
@@ -746,9 +1104,9 @@ export default function MessagesPage() {
|
||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||
/>
|
||||
<div className="pm-thread-head__meta">
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||
<span className="pm-thread-head__sub">私信对话</span>
|
||||
<span className="pm-thread-head__name">{title}</span>
|
||||
</div>
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__profile">主页</Link>
|
||||
</header>
|
||||
|
||||
<div
|
||||
@@ -775,17 +1133,35 @@ export default function MessagesPage() {
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
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 (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
|
||||
>
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||||
<div className="pm-bubble__text">{m.content}</div>
|
||||
<div className="pm-bubble__meta">
|
||||
<time>{formatDateTime(m.created_at)}</time>
|
||||
{!mine && avatar}
|
||||
<div className="pm-bubble-stack">
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||||
<PmBubbleContent content={m.content} />
|
||||
</div>
|
||||
<time className="pm-bubble__time">{formatDateTime(m.created_at)}</time>
|
||||
</div>
|
||||
{mine && avatar}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -798,10 +1174,11 @@ export default function MessagesPage() {
|
||||
{canCompose && (
|
||||
<footer className="pm-composer">
|
||||
<textarea
|
||||
ref={draftRef}
|
||||
className="pm-composer__input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
value={draftText}
|
||||
onChange={(e) => updateDraftText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
placeholder={`发送给 ${title}…`}
|
||||
onKeyDown={(e) => {
|
||||
@@ -811,15 +1188,80 @@ export default function MessagesPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="pm-composer__send"
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send size={16} />
|
||||
发送
|
||||
</Button>
|
||||
{draftEmbeds.length > 0 && (
|
||||
<ul className="pm-composer__embeds" aria-label="待发送图片">
|
||||
{draftEmbeds.map((emb) => (
|
||||
<li key={emb.id} className="pm-composer__embed">
|
||||
<img src={emb.url} alt={emb.name} loading="lazy" />
|
||||
<button
|
||||
type="button"
|
||||
className="pm-composer__embed-remove"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 { api } from '../api/client';
|
||||
import type { CommunityShowcaseItem } from '../api/types';
|
||||
@@ -7,12 +10,65 @@ import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
const { branding } = useSiteBranding();
|
||||
const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>(
|
||||
'showcase',
|
||||
() => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
|
||||
{ revalidateEmpty: true },
|
||||
);
|
||||
|
||||
usePageSEO({
|
||||
@@ -24,52 +80,65 @@ export default function ShowcasePage() {
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="showcase-page">
|
||||
<header className="showcase-head">
|
||||
<div className="showcase-head-mark" aria-hidden>
|
||||
<Globe2 size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="showcase-title">开源部署展柜</h1>
|
||||
<p className="showcase-desc">
|
||||
<div className="feed-panel list-page-panel showcase-panel">
|
||||
<header className="list-page-panel__head showcase-head">
|
||||
<Button variant="ghost" size="sm" className="list-page-panel__back" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<div className="showcase-head__title-row">
|
||||
<h1 className="page-title">开源部署展柜</h1>
|
||||
{!loading && items.length > 0 && (
|
||||
<span className="showcase-head__count">精选 {items.length} 站</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="page-desc">
|
||||
以下站点自愿开启社区上报,并由官方演示站精选推荐(非全量目录)
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="showcase-empty">暂无精选实例</p>
|
||||
) : (
|
||||
<ul className="showcase-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.site_url} className="showcase-item">
|
||||
<a
|
||||
href={item.site_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="showcase-item-link"
|
||||
>
|
||||
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span>
|
||||
<span className="showcase-item-url">
|
||||
{item.site_url}
|
||||
<ExternalLink size={12} aria-hidden />
|
||||
</span>
|
||||
{(item.featured_note || item.version) && (
|
||||
<span className="showcase-item-meta">
|
||||
{item.featured_note || null}
|
||||
{item.featured_note && item.version ? ' · ' : null}
|
||||
{item.version ? `v${item.version}` : null}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="showcase-empty list-page-panel__empty">
|
||||
<Globe2 size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>暂无精选实例</p>
|
||||
<span>有站点被官方推荐后会出现在这里</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="showcase-list">
|
||||
{items.map((item) => {
|
||||
const name = item.site_name || '未命名站点';
|
||||
return (
|
||||
<li key={item.site_url} className="showcase-item">
|
||||
<a
|
||||
href={item.site_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="showcase-item-link"
|
||||
>
|
||||
<ShowcaseFavicon name={name} url={item.site_url} />
|
||||
<span className="showcase-item-body">
|
||||
<span className="showcase-item-name">{name}</span>
|
||||
<span className="showcase-item-url">
|
||||
{hostLabel(item.site_url)}
|
||||
<ExternalLink size={12} aria-hidden />
|
||||
</span>
|
||||
{item.featured_note ? (
|
||||
<span className="showcase-item-note">{item.featured_note}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{item.version ? (
|
||||
<span className="showcase-item-version">v{item.version}</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<InFlowSiteFooter />
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useSessionResource } from '../hooks/useSessionResource';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import FeedPagination from '../components/FeedPagination';
|
||||
import ComposeMessageDialog from '../components/ComposeMessageDialog';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
@@ -44,7 +43,6 @@ export default function UserProfilePage() {
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
||||
|
||||
const [msgOpen, setMsgOpen] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [postPage, setPostPage] = useState(1);
|
||||
|
||||
@@ -183,7 +181,7 @@ export default function UserProfilePage() {
|
||||
nav(loginPath(userPath(profile.id)));
|
||||
return;
|
||||
}
|
||||
setMsgOpen(true);
|
||||
nav(`/messages?peer=${profile.id}`);
|
||||
}}
|
||||
>
|
||||
<Mail size={14} />
|
||||
@@ -265,16 +263,6 @@ export default function UserProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
|
||||
{!isSelf && profile && me && (
|
||||
<ComposeMessageDialog
|
||||
open={msgOpen}
|
||||
onOpenChange={setMsgOpen}
|
||||
toUserId={profile.id}
|
||||
toNickname={profile.nickname}
|
||||
onSent={() => nav(`/messages?peer=${profile.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user