feat: 重设计私信页并优化开源展柜体验
私信改为飞书风布局,支持缩略图表情/图片、按会话草稿与用户搜索发起会话;主页发私信直达消息页。开源展柜修复空列表粘滞并改版目录式 UI。
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => ``),
|
||||||
|
];
|
||||||
|
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 图片 `` */
|
||||||
|
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>
|
||||||
|
{c.last_message && (
|
||||||
<span className="pm-conv-item__time">
|
<span className="pm-conv-item__time">
|
||||||
{formatDateTime(c.last_message?.created_at || c.updated_at)}
|
{formatConvListTime(c.last_message.created_at || c.updated_at)}
|
||||||
</span>
|
</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')}
|
||||||
>
|
>
|
||||||
|
{!mine && avatar}
|
||||||
|
<div className="pm-bubble-stack">
|
||||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||||||
<div className="pm-bubble__text">{m.content}</div>
|
<PmBubbleContent content={m.content} />
|
||||||
<div className="pm-bubble__meta">
|
|
||||||
<time>{formatDateTime(m.created_at)}</time>
|
|
||||||
</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 && (
|
||||||
|
<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"
|
className="pm-composer__send"
|
||||||
loading={sending}
|
disabled={isDraftEmpty(draftText, draftEmbeds) || sending}
|
||||||
disabled={!draft.trim()}
|
|
||||||
onClick={() => void send()}
|
onClick={() => void send()}
|
||||||
>
|
>
|
||||||
<Send size={16} />
|
{sending ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
发送
|
发送
|
||||||
</Button>
|
<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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,26 +80,36 @@ 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 />
|
||||||
|
返回
|
||||||
|
</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>
|
</div>
|
||||||
<div>
|
<p className="page-desc">
|
||||||
<h1 className="showcase-title">开源部署展柜</h1>
|
|
||||||
<p className="showcase-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 />
|
||||||
|
<p>暂无精选实例</p>
|
||||||
|
<span>有站点被官方推荐后会出现在这里</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="showcase-list">
|
<ul className="showcase-list">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const name = item.site_name || '未命名站点';
|
||||||
|
return (
|
||||||
<li key={item.site_url} className="showcase-item">
|
<li key={item.site_url} className="showcase-item">
|
||||||
<a
|
<a
|
||||||
href={item.site_url}
|
href={item.site_url}
|
||||||
@@ -51,21 +117,24 @@ export default function ShowcasePage() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="showcase-item-link"
|
className="showcase-item-link"
|
||||||
>
|
>
|
||||||
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span>
|
<ShowcaseFavicon name={name} url={item.site_url} />
|
||||||
|
<span className="showcase-item-body">
|
||||||
|
<span className="showcase-item-name">{name}</span>
|
||||||
<span className="showcase-item-url">
|
<span className="showcase-item-url">
|
||||||
{item.site_url}
|
{hostLabel(item.site_url)}
|
||||||
<ExternalLink size={12} aria-hidden />
|
<ExternalLink size={12} aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
{(item.featured_note || item.version) && (
|
{item.featured_note ? (
|
||||||
<span className="showcase-item-meta">
|
<span className="showcase-item-note">{item.featured_note}</span>
|
||||||
{item.featured_note || null}
|
) : null}
|
||||||
{item.featured_note && item.version ? ' · ' : null}
|
|
||||||
{item.version ? `v${item.version}` : null}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{item.version ? (
|
||||||
|
<span className="showcase-item-version">v{item.version}</span>
|
||||||
|
) : null}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user