perf(start): 优化高分屏卡顿 + 背景图按横竖屏切换
性能: - 光标圆环去掉 mixBlendMode:difference(跟随时触发全屏重合成, 高分屏掉帧主因),并删除位置弹簧平滑,改为 1:1 跟手 - .glass-panel 的 backdrop-filter 模糊 20px→10px,每帧重算开销减半 背景: - 横/竖图分别放 landscape//portrait/ 子目录(同名) - 新增 useMediaQuery hook,按 (orientation: portrait) 选图, 转屏实时切换;竖图缺失自动回退同名横图 - 更新 startpage/README 说明新结构 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ -1,32 +1,42 @@
|
||||
# Startpage 背景图
|
||||
|
||||
导航起始页(`src/components/StartPage.tsx`)的背景图放在这个目录里,
|
||||
**按昼夜自动切换**:
|
||||
**按昼夜 + 屏幕方向自动切换**。
|
||||
|
||||
- 白天 → `light.*`(如 `light.jpg`)
|
||||
- 夜晚 → `dark.*`(如 `dark.png`)
|
||||
## 目录结构
|
||||
|
||||
放好这两张文件即可生效,无需改代码。
|
||||
```
|
||||
startpage/
|
||||
├─ landscape/ 横屏图(电脑、手机横放)
|
||||
│ ├─ No.1.webp ~ No.5.webp 白天候选
|
||||
│ └─ Night.webp 夜间
|
||||
└─ portrait/ 竖屏图(手机竖放),命名与 landscape 完全相同
|
||||
├─ No.1.webp ~ No.5.webp
|
||||
└─ Night.webp
|
||||
```
|
||||
|
||||
放好文件即可生效,无需改代码。**竖图可以只放一部分**——缺的那张会自动
|
||||
回退到同名横图。
|
||||
|
||||
## 工作方式
|
||||
|
||||
`StartPage` 用 Vite 的 `import.meta.glob` 索引本目录,按文件名精确取
|
||||
`light.*` / `dark.*`,再按昼夜选用。
|
||||
`StartPage` 用 Vite 的 `import.meta.glob` 分别索引 `landscape/`、`portrait/`:
|
||||
|
||||
昼夜判断:**优先用天气接口返回的图标码**(晴/多云/阵雨类区分昼夜),
|
||||
图标码判断不了的天气(雨、雪、雾、阴等无昼夜之分)则用**本地时间兜底**
|
||||
(18:00–06:00 视为夜晚)。
|
||||
- **方向**:`useMediaQuery('(orientation: portrait)')` 判断竖屏(高 ≥ 宽)。
|
||||
竖屏优先取 `portrait/` 的图,转屏/缩窗口会实时切换。
|
||||
- **白天**:从 `No.1`~`No.5` 里随机取一张(整页生命周期内固定,不乱跳)。
|
||||
- **夜间**:用 `Night`。判断为本地时间 `0:00–6:00`。
|
||||
|
||||
回退顺序:选中的那张缺失 → 用另一张 → 都缺则用目录里排序第一张 →
|
||||
再无则用中性渐变(构建始终成功)。
|
||||
回退顺序:竖图缺失 → 同名横图 → 横图里排序第一张 → 再无则用中性渐变
|
||||
(构建始终成功)。
|
||||
|
||||
## 命名与规格
|
||||
|
||||
- 必须命名为 `light.*` 和 `dark.*`(扩展名任意:`.jpg/.jpeg/.png/.webp`)。
|
||||
其它名字(如 `dark33.png`)不会被选用,只会白白打进包里,建议删掉。
|
||||
- 格式:`webp`(最小)> `jpg` > `png`
|
||||
- 横图、够大:≥ 1920×1080,最好 2560×1440+
|
||||
- 图上还会叠一层暗色 scrim + 半透明黑遮罩(保证文字可读),所以偏暗、
|
||||
- 白天图必须叫 `No.<数字>.*`(如 `No.1.webp`),夜间图叫 `Night.*`。
|
||||
其它名字不会被选用,只会白白打进包里。
|
||||
- 扩展名任意:`.jpg/.jpeg/.png/.webp`,体积 `webp` > `jpg` > `png`。
|
||||
- 横图够大:≥ 1920×1080,最好 2560×1440+;竖图按手机比例(如 1080×2340)。
|
||||
- 图上还会叠一层暗色 scrim + 半透明黑遮罩(保证文字可读),偏暗、
|
||||
低对比的图效果最好。
|
||||
|
||||
## 本地预览(仅 `npm run dev`)
|
||||
@ -37,3 +47,5 @@
|
||||
- `?fx=rain` / `?fx=snow` / `?fx=thunder` / `?fx=none` —— 强制动态效果
|
||||
|
||||
例:`http://localhost:5173/?bg=dark&fx=snow`
|
||||
|
||||
竖屏效果用浏览器开发者工具的设备模拟(Device Toolbar)切竖屏即可预览。
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 331 KiB |
@ -1,13 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, useMotionValue, useSpring } from 'framer-motion'
|
||||
import { motion, useMotionValue } from 'framer-motion'
|
||||
|
||||
// 跟随鼠标的小圆环(仿 Few & Far)。
|
||||
// 仅在精确指针设备启用;触摸设备与 reduced-motion 下不挂载。
|
||||
export function CursorFollower() {
|
||||
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
|
||||
const x = useMotionValue(-100)
|
||||
const y = useMotionValue(-100)
|
||||
const springX = useSpring(x, { stiffness: 350, damping: 28, mass: 0.4 })
|
||||
const springY = useSpring(y, { stiffness: 350, damping: 28, mass: 0.4 })
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
@ -47,11 +46,11 @@ export function CursorFollower() {
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed left-0 top-0 z-[100]"
|
||||
style={{ x: springX, y: springY }}
|
||||
style={{ x, y }}
|
||||
>
|
||||
<motion.span
|
||||
className="block rounded-full border border-accent"
|
||||
style={{ translateX: '-50%', translateY: '-50%', mixBlendMode: 'difference' }}
|
||||
style={{ translateX: '-50%', translateY: '-50%' }}
|
||||
animate={{
|
||||
width: hovering ? 54 : 24,
|
||||
height: hovering ? 54 : 24,
|
||||
|
||||
@ -2,29 +2,43 @@ import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { fadeUp, stagger } from './motion'
|
||||
import { useWeather } from '../hooks/useWeather'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { Greeting } from './start/Greeting'
|
||||
import { SearchBar } from './start/SearchBar'
|
||||
import { CategoryDock } from './start/CategoryDock'
|
||||
import { WeatherFx } from './start/WeatherFx'
|
||||
|
||||
// 背景图:放 src/assets/startpage/ 下。6:00–24:00 从 No.1~No.5 里随机取一张;
|
||||
// 0:00–6:00 用 Night。缺图时互相回退,再无则用渐变。说明见该目录 README。
|
||||
const bgFiles = import.meta.glob<string>('../assets/startpage/*.{jpg,jpeg,png,webp}', {
|
||||
// 背景图:横图放 src/assets/startpage/landscape/,竖图放 src/assets/startpage/portrait/(同名)。
|
||||
// 6:00–24:00 从 No.1~No.5 里随机取一张;0:00–6:00 用 Night。
|
||||
// 竖屏优先用 portrait/ 的图,缺图则回退到同名横图,再无则用渐变。说明见该目录 README。
|
||||
const landscapeFiles = import.meta.glob<string>('../assets/startpage/landscape/*.{jpg,jpeg,png,webp}', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
})
|
||||
const portraitFiles = import.meta.glob<string>('../assets/startpage/portrait/*.{jpg,jpeg,png,webp}', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
function bgByName(name: string): string | undefined {
|
||||
return Object.entries(bgFiles).find(([p]) => p.toLowerCase().includes(`/${name}.`))?.[1]
|
||||
interface BgSet {
|
||||
dayBgs: string[] // 白天候选:No.1~No.5(按编号排序)
|
||||
nightBg?: string // 夜间:Night
|
||||
}
|
||||
|
||||
// 白天候选:No.1~No.5(按编号排序);夜间:Night。
|
||||
const dayBgs = Object.entries(bgFiles)
|
||||
// 从一组 glob 结果里拆出白天候选与夜间图。
|
||||
function buildSet(files: Record<string, string>): BgSet {
|
||||
const dayBgs = Object.entries(files)
|
||||
.filter(([p]) => /\/no\.\d+\./i.test(p))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, url]) => url)
|
||||
const nightBg = bgByName('night')
|
||||
const nightBg = Object.entries(files).find(([p]) => p.toLowerCase().includes('/night.'))?.[1]
|
||||
return { dayBgs, nightBg }
|
||||
}
|
||||
|
||||
const landscape = buildSet(landscapeFiles)
|
||||
const portrait = buildSet(portraitFiles)
|
||||
|
||||
// 昼夜判断:纯按本地时间,0:00–6:00 为夜。
|
||||
function isNight(): boolean {
|
||||
@ -56,8 +70,13 @@ export function StartPage() {
|
||||
|
||||
const icon = wx.data && wx.data.ok ? wx.data.icon : undefined
|
||||
|
||||
// 竖屏(高≥宽)优先用竖版图,缺图回退到同名横图。
|
||||
const isPortrait = useMediaQuery('(orientation: portrait)')
|
||||
|
||||
// 白天随机选一张,整页生命周期内固定,避免每分钟一拍时背景乱跳。
|
||||
const [dayIdx] = useState(() => Math.floor(Math.random() * Math.max(dayBgs.length, 1)))
|
||||
// 索引按两套候选的最大长度取,方向切换时也不越界。
|
||||
const dayCount = Math.max(landscape.dayBgs.length, portrait.dayBgs.length, 1)
|
||||
const [dayIdx] = useState(() => Math.floor(Math.random() * dayCount))
|
||||
|
||||
// 开发预览(仅 DEV):?bg=light|dark 强制背景;?fx=rain|snow|thunder|none 强制动效。
|
||||
// 例:http://localhost:5173/?bg=dark&fx=snow
|
||||
@ -65,8 +84,11 @@ export function StartPage() {
|
||||
const ovBg = ov?.get('bg')
|
||||
const ovFx = ov?.get('fx')
|
||||
|
||||
const i = dayIdx % dayCount
|
||||
const dayBg = (isPortrait ? portrait.dayBgs[i] : undefined) ?? landscape.dayBgs[i] ?? landscape.dayBgs[0]
|
||||
const nightBg = (isPortrait ? portrait.nightBg : undefined) ?? landscape.nightBg
|
||||
|
||||
const night = ovBg ? ovBg === 'dark' : isNight()
|
||||
const dayBg = dayBgs[dayIdx] ?? dayBgs[0]
|
||||
const bg = (night ? nightBg : dayBg) ?? dayBg ?? nightBg
|
||||
|
||||
let kind = fxKind(icon)
|
||||
|
||||
15
Client/src/hooks/useMediaQuery.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
// 订阅一条媒体查询,匹配状态变化时自动重渲染。
|
||||
// 典型用法:useMediaQuery('(orientation: portrait)') 判断竖屏。
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => {
|
||||
const mql = window.matchMedia(query)
|
||||
mql.addEventListener('change', onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false, // SSR/首帧兜底:当作不匹配
|
||||
)
|
||||
}
|
||||
@ -212,8 +212,10 @@ body {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
0 10px 30px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(20px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||
/* blur 半径直接决定每帧重算模糊的成本(∝ 半径×面积),高分屏下尤甚;
|
||||
20px→10px 观感几乎无差,开销减半。见 .glass-sheet 处的同类取舍。 */
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
}
|
||||
|
||||
/* ── reduced-motion 降级 ────────────────────────────────────── */
|
||||
|
||||