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

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

View File

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