feat(web): show a skeleton while a space loads

Draw the page frame instead of a blank screen, fade it in only after 300ms,
and surface load errors with retry and back-to-home actions.
This commit is contained in:
justme committed 2026-10-16 05:44:58 +08:00
1 parent e79fbf1112
commit 22f272e361
4 files changed
+193 -4

No files matched your search

+2 -1
View File
@@ -41,7 +41,8 @@ export function App() {
const setPendingLink = useChannels((s) => s.setPendingLink);
const sharesHydrated = useShares((s) => s.hydrated);
const pendingShareLink = useShares((s) => s.pendingLink);
const wsReady = useWorkspace((s) => s.ready);
// 加载失败也算「结束」:让骨架屏上的错误与重试露出来,而不是被启动动画盖住
const wsReady = useWorkspace((s) => s.ready || s.error !== null);
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
+6 -3
View File
@@ -2,16 +2,19 @@ import { useEffect, useState } from 'react';
import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace';
import { CreateSheet } from '@/features/home/CreateSheet';
import { ChannelSkeleton } from './ChannelSkeleton';
import { channelPath, navigate } from './navigation';
/** 打开(订阅)空间,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready);
const error = useWorkspace((s) => s.error);
const [creating, setCreating] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => open(id), [open, id]);
useEffect(() => open(id), [open, id, attempt]);
return ready ? <><Workspace onCreateChannel={() => setCreating(true)} />
{creating ? <CreateSheet onClose={() => setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}</> : null;
return !ready ? <ChannelSkeleton error={error ?? undefined} onRetry={() => setAttempt((n) => n + 1)} onHome={() => navigate('/')} /> : <><Workspace onCreateChannel={() => setCreating(true)} />
{creating ? <CreateSheet onClose={() => setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}</>;
}
+154
View File
@@ -0,0 +1,154 @@
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--bar-h) minmax(0, 1fr);
height: 100vh;
height: 100dvh;
background: var(--bg);
animation: skeleton-appear 0.2s ease-out 0.3s backwards;
}
.bar {
display: flex;
align-items: center;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
grid-column: 1 / -1;
}
.block {
display: block;
height: 12px;
border-radius: var(--radius-sm);
background: var(--surface-2);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
.title {
width: 120px;
height: 16px;
}
.rail,
.nav,
.files {
display: none;
}
.chat {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4) var(--space-3);
background: var(--surface);
}
.message {
display: flex;
gap: var(--space-3);
}
.avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: var(--radius-avatar);
}
.lines {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding-top: var(--space-1);
}
.name {
width: 96px;
}
.composer {
height: 44px;
margin-top: auto;
border-radius: var(--radius-lg);
animation: none;
}
.error {
position: fixed;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-6);
background: var(--bg);
text-align: center;
}
.error p {
margin: 0;
color: var(--danger);
}
.error div {
display: flex;
gap: var(--space-2);
}
@media (width >= 900px) and (height >= 500px) {
.page {
grid-template-columns: 64px 240px minmax(260px, 1fr) clamp(320px, 36%, 560px);
}
.rail,
.nav,
.files {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
.rail {
align-items: center;
padding-inline: 0;
}
.tile {
width: 44px;
height: 44px;
border-radius: var(--radius-lg);
}
.nav {
border-right: 1px solid var(--line);
}
.files {
border-left: 1px solid var(--line);
}
}
@keyframes skeleton-appear {
from {
opacity: 0;
}
}
@keyframes skeleton-pulse {
50% {
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.page,
.block {
animation: none;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { Button } from '@/ui/Button';
import styles from './ChannelSkeleton.module.css';
const ROWS = [72, 48, 88, 36, 64];
/** 频道加载中:先画出页面框架(Apple HIG「别留白屏」),内容到了再替换。超过约 300ms 才可见,避免快的时候闪一下 */
export function ChannelSkeleton({ error, onRetry, onHome }: { error?: string; onRetry: () => void; onHome: () => void }) {
// 与手机聊天页一致:账号入口不浮在骨架屏上
useEffect(() => {
document.documentElement.dataset.chatShell = '';
return () => { delete document.documentElement.dataset.chatShell; };
}, []);
return <div className={styles.page} role="status" aria-label={error ? '频道加载失败' : '正在加载频道'} aria-busy={!error}>
<header className={styles.bar}><span className={`${styles.block} ${styles.title}`} /></header>
<aside className={styles.rail}><span className={`${styles.block} ${styles.tile}`} /></aside>
<aside className={styles.nav}>{[56, 40, 48].map((w) => <span key={w} className={styles.block} style={{ width: `${w}%` }} />)}</aside>
<main className={styles.chat}>
{ROWS.map((w, i) => <div key={i} className={styles.message}>
<span className={`${styles.block} ${styles.avatar}`} />
<div className={styles.lines}><span className={`${styles.block} ${styles.name}`} /><span className={styles.block} style={{ width: `${w}%` }} /></div>
</div>)}
<div className={`${styles.block} ${styles.composer}`} />
</main>
<aside className={styles.files}>{[80, 60, 70, 50].map((w) => <span key={w} className={styles.block} style={{ width: `${w}%` }} />)}</aside>
{error ? <div className={styles.error} role="alert">
<p>频道没能加载:{error}</p>
<div><Button variant="primary" onClick={onRetry}>重试</Button><Button variant="ghost" onClick={onHome}>回到首页</Button></div>
</div> : null}
</div>;
}