From 22f272e3616bd8a25fbbb6846cd8415834d7c31d Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 16 Oct 2026 05:44:58 +0800 Subject: [PATCH] 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. --- web/src/app/App.tsx | 3 +- web/src/app/ChannelScreen.tsx | 9 +- web/src/app/ChannelSkeleton.module.css | 154 +++++++++++++++++++++++++ web/src/app/ChannelSkeleton.tsx | 31 +++++ 4 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 web/src/app/ChannelSkeleton.module.css create mode 100644 web/src/app/ChannelSkeleton.tsx diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index caa9908..3994671 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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); diff --git a/web/src/app/ChannelScreen.tsx b/web/src/app/ChannelScreen.tsx index b4d6f5f..b6cf316 100644 --- a/web/src/app/ChannelScreen.tsx +++ b/web/src/app/ChannelScreen.tsx @@ -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 ? <> setCreating(true)} /> - {creating ? setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null} : null; + return !ready ? setAttempt((n) => n + 1)} onHome={() => navigate('/')} /> : <> setCreating(true)} /> + {creating ? setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}; } diff --git a/web/src/app/ChannelSkeleton.module.css b/web/src/app/ChannelSkeleton.module.css new file mode 100644 index 0000000..929b61f --- /dev/null +++ b/web/src/app/ChannelSkeleton.module.css @@ -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; + } +} diff --git a/web/src/app/ChannelSkeleton.tsx b/web/src/app/ChannelSkeleton.tsx new file mode 100644 index 0000000..eb341e8 --- /dev/null +++ b/web/src/app/ChannelSkeleton.tsx @@ -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
+
+ + +
+ {ROWS.map((w, i) =>
+ +
+
)} +
+
+ + {error ?
+

频道没能加载:{error}

+
+
: null} +
; +}