fix(ui): 修复 React Compiler lint 命中并恢复规则为 error (#1711)
* fix(ui): 主题与侧栏偏好改用 useSyncExternalStore 读取,去掉 effect 内 setState
react-hooks/set-state-in-effect 命中 4 处,都是「挂载后在 effect 里读浏览器状态再 setState」:
- app/(app)/layout.tsx:117 侧栏折叠偏好、:136 主题模式:两者都是 localStorage 里的值。
新增 useLocalStorageValue(key),用 useSyncExternalStore 订阅:服务端与水合期快照固定为
null(与 SSR 输出一致,不会水合不一致),水合完成后 React 自己用真实值重渲染一次;
写入走 setter 并通知本页订阅者。localStorage 写失败时退回内存值,本页内仍可切换,
与原先 try/catch 后照常 setState 的行为一致。
- app/lib/utils/index.tsx:66 useSystemTheme:matchMedia 是外部状态,改为 useSyncExternalStore
订阅 change 事件,服务端快照 light,与原来 useState('light') 再 effect 校正的结果一致。
- app/ui/ThemeButton.tsx:30:原来点击后先置 switchTrigger,再在 effect 里读标志位切主题、
setMode、清标志位,图标也在 effect 里 setState。改为点击事件里直接算出下一模式并
applyThemeMode + setMode,图标由 mode 派生,不再有中间 state 与 effect;切换结果相同,
少一轮渲染。setMode 的类型收窄为 (mode: string) => void,调用方只传字符串。
useTheme 未改:它的首次跳过逻辑仍然成立(首帧主题由 <head> 内联脚本设置)。
* fix(ui): 日志页、空间配置页、账号列表与模板表单去掉 effect 内同步 setState
react-hooks/set-state-in-effect 其余 5 处:
- app/(app)/logviewer/page.tsx:133:connectWebSocket() 开头同步 setIsLoading(true)/setLogs([])
再建连接,effect 与「刷新」按钮共用。拆开:effect 只负责建立 WebSocket、把消息写进
state、cleanup 时关闭(上一条连接不再靠 ref 手动 close);清空日志 + 进入加载态移到触发
重连的事件里(切 Tab 的 onChange、刷新按钮),刷新通过 connectSeq 计数器让 effect 重跑。
首次挂载时两个 state 本来就是初始值,其余时序与原来一致;顺带删掉不再需要的
exhaustive-deps 禁用注释。
- app/(app)/dashboard/page.tsx:101:校验失败后先切 Tab / 平台,再在 effect 里滚到出错字段并
把 pendingField 清空。改为 flushSync 同步提交切面板的 state,随后直接 scrollIntoView +
focus(React 文档推荐的「更新后读 DOM」写法),去掉 pendingField 这个中间 state。
onSubmitFail 由 Semi 在 Promise.catch 里调用,不在渲染期,flushSync 合法。
- app/lib/use-streamers.ts:26 useBiliUsers:data 为空时同步 setList([])。改为返回值直接派生
(无账号 → 空数组常量),effect 里只保留异步补全;data 为空数组时 Promise.all 解析为 []
仍会把上一批结果清掉,之后再有账号不会先闪旧数据。
- app/ui/TemplateFields.tsx:183:挂载后从 formApi.getValue('dtime') 算滚轮初始选中值。
Semi Form 在构造函数里就把 initValues 放进 store,首次渲染即可读到,改为 useState 的
惰性初始值(换算逻辑抽成 delayFromDtime),结果与 effect 版本相同。
- app/(app)/components/BackgroundSetter.tsx:71:hasBg / opacity 挂载后从 localStorage 读。
改为 useState 惰性初始值;两个值只在面板展开后渲染,SSR 首帧输出不受影响。
* fix(ui): 修复渲染期不纯写法:卡片实时时长、平台插件查找、time_range 转换
- react-hooks/purity app/ui/StreamerCard.tsx:101:渲染期调 Date.now() 算「已直播多久」。
新增共享时钟 useNowSec()(app/lib/use-dashboard.ts):模块级 store 由定时器每秒推进,
组件用 useSyncExternalStore 读取,首个订阅者接入时校准一次。时长拆成 LiveDuration 子组件,
只有录制中的卡片挂载它,其余卡片不随时钟重渲染。显示值语义不变(开播至今,同一
formatDuration),刷新节奏从「随 10 s 轮询重渲染时更新」变为每秒更新。timeAgo 内部也用
Date.now() 但未被规则命中(在工具函数里),本次不动。
- react-hooks/static-components app/ui/OverrideModal.tsx:297:JSX 里用 IIFE 调 platformSetting()
拿到组件再渲染,规则把它当成「渲染期创建的组件」。实际是按 url 从模块级常量表
SupportedPlatforms 取组件。改为先找出匹配的键,再按键从常量表取值,让「同一引用」这一点
在代码上可见;匹配顺序与结果不变。
- react-hooks/immutability app/ui/TemplateModal.tsx:121:把 props.entity.time_range 由 JSON
字串就地改写成 Date[]。改为 useMemo 生成一份本地副本作为 Form 的 initValues,解析失败
保持原样并 console.error(同原逻辑),不再修改上层传下来的对象。
* chore(lint): 恢复 React Compiler 系列规则为 eslint-plugin-react-hooks 的默认 error
#1710 升级 eslint-plugin-react-hooks 7 时把 set-state-in-effect / static-components /
purity / immutability 四条降为 warn,留给专门 PR 处理。命中的 12 处已在前三个 commit 修完,
这里删掉覆盖,回到插件 recommended 的 error 级别。
* fix(ui): useTheme 不再回写 localStorage,mode 只由 useLocalStorageValue 的 setter 持久化
mode 改由 useSyncExternalStore 从 localStorage 读取后,同一个键有了两个写入方。
StrictMode 下 effect 会在挂载时执行两次,useTheme 的「跳过首次」只能挡住第一次,
第二次会用水合期的默认值 auto 把用户刚保存的主题覆盖掉,再由 store 读回 auto——
`next dev` 里每次刷新主题都会丢(生产构建没有双次 effect,不受影响)。
去掉 useTheme 里的 setItem:切换主题时 setter 已经写过同一个值;系统配色变化时原来会
把 auto 写进去,去掉后键保持缺省,读出来同样按 auto 处理,行为一致。
* fix(ui): 登录页在手机宽度下卡片超出视口、两侧被裁切
review 意见(#1711 下 ForgQi):手机尺寸下登录页两侧被裁切。
原因:三个输入框写死 style={{ width: 440 }},卡片宽度由它们撑成 552px(440 + 56×2 内边距),
.main 居中放置、.frame 又是 overflow: hidden,窄于 552 的屏幕上卡片两侧直接被裁掉。
改法:去掉输入框的固定宽度,卡片定宽 552px 并允许 max-width: 100% 收窄到视口,
.inputs 改为 align-items: stretch 让字段撑满卡片内容区(Semi 的 .semi-input-wrapper 默认 width: 100%),
≤480px 时内边距收到 32px 24px。桌面 1440 浅 / 深色截图与修改前像素 0 差异;390px 下卡片 370px、
输入框 322px、无横向溢出,注册模式(三个输入框)同样。
This commit is contained in:
15 files changed
+241
-184
No files matched your search
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Slider, Toast, Tooltip } from '@douyinfe/semi-ui'
|
||||
import { IconImage, IconUpload, IconDelete } from '@douyinfe/semi-icons'
|
||||
import {
|
||||
@@ -64,13 +64,10 @@ function compressImage(file: File): Promise<string> {
|
||||
export default function BackgroundSetter() {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hasBg, setHasBg] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0.35)
|
||||
|
||||
useEffect(() => {
|
||||
setHasBg(!!getBg())
|
||||
setOpacity(getBgOpacity())
|
||||
}, [])
|
||||
// 初始值直接从 localStorage 读(服务端返回默认值)。这两个状态只在面板展开后才渲染,
|
||||
// 首屏 SSR 输出不受影响,不需要挂载后在 effect 里 setState
|
||||
const [hasBg, setHasBg] = useState(() => !!getBg())
|
||||
const [opacity, setOpacity] = useState(getBgOpacity)
|
||||
|
||||
const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
@@ -78,7 +79,8 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
|
||||
|
||||
// 校验失败时出错字段可能藏在未选中的 Tab / 平台面板里(字段全部挂载、只切 hidden),
|
||||
// 用户看不到红字也不知道为什么保存没反应。这里切到第一个出错字段所在的面板并滚过去。
|
||||
const [pendingField, setPendingField] = useState<string | null>(null)
|
||||
// 切面板的 state 更新用 flushSync 同步提交,之后字段已可见,直接滚动、聚焦即可,
|
||||
// 不再需要「记一个待定位字段 → effect 里滚动再清掉」的中间 state。
|
||||
const handleSubmitFail = (errors: Record<string, unknown>) => {
|
||||
const fields = errorFieldPaths(errors)
|
||||
.map(path => ({ path, el: fieldElement(path) }))
|
||||
@@ -88,18 +90,14 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
|
||||
if (!first) return
|
||||
const tab = first.el.closest<HTMLElement>('[data-tab]')?.dataset.tab
|
||||
const platform = first.el.closest<HTMLElement>('[data-platform]')?.dataset.platform
|
||||
if (tab) setActiveTab(tab)
|
||||
if (platform) setActivePlatform(platform)
|
||||
setPendingField(first.path)
|
||||
flushSync(() => {
|
||||
if (tab) setActiveTab(tab)
|
||||
if (platform) setActivePlatform(platform)
|
||||
})
|
||||
first.el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
first.el.querySelector<HTMLElement>('input, textarea')?.focus({ preventScroll: true })
|
||||
Toast.warning(`有 ${fields.length} 项未通过校验,已定位到第一项`)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!pendingField) return
|
||||
const el = fieldElement(pendingField)
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
el?.querySelector<HTMLElement>('input, textarea')?.focus({ preventScroll: true })
|
||||
setPendingField(null)
|
||||
}, [pendingField])
|
||||
|
||||
if (isLoading) {
|
||||
return <>Loading</>
|
||||
|
||||
+9
-27
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import './bg-global.css'
|
||||
import { useGlobalBackgroundInit } from '../lib/useGlobalBackground'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@@ -9,7 +9,7 @@ import Image from 'next/image'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../lib/api-streamer'
|
||||
import ThemeButton from '../ui/ThemeButton'
|
||||
import { useSystemTheme, useTheme } from '../lib/utils'
|
||||
import { useLocalStorageValue, useSystemTheme, useTheme } from '../lib/utils'
|
||||
import { formatVersion } from '../lib/status'
|
||||
import { SLOW_REFRESH_MS } from '../lib/use-dashboard'
|
||||
import { useIsMobile } from '../lib/useIsMobile'
|
||||
@@ -105,36 +105,18 @@ const SIDER_KEY = 'biliup_sider_collapsed'
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const isMobile = useIsMobile()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
const [mode, setMode] = useState<any>('auto')
|
||||
|
||||
useGlobalBackgroundInit()
|
||||
|
||||
// 折叠偏好持久化
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(SIDER_KEY) === '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((c) => {
|
||||
try {
|
||||
localStorage.setItem(SIDER_KEY, c ? '0' : '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return !c
|
||||
})
|
||||
}
|
||||
// 折叠偏好持久化:localStorage 是数据源,首屏(SSR / 水合)按未折叠渲染,水合后自动切到已保存的值
|
||||
const [siderPref, setSiderPref] = useLocalStorageValue(SIDER_KEY)
|
||||
const collapsed = siderPref === '1'
|
||||
const toggleCollapsed = () => setSiderPref(collapsed ? '0' : '1')
|
||||
|
||||
// 主题
|
||||
useEffect(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('mode') : null
|
||||
if (saved) setMode(saved)
|
||||
}, [])
|
||||
// 主题:同上,未保存过时为 auto
|
||||
const [savedMode, setMode] = useLocalStorageValue('mode')
|
||||
const mode = savedMode ?? 'auto'
|
||||
const systemTheme = useSystemTheme()
|
||||
useTheme(mode, systemTheme)
|
||||
|
||||
|
||||
@@ -83,18 +83,20 @@ export default function LogViewer() {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('ds_update')
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
// 每加一就重建一次 WebSocket(「刷新」按钮);切换 Tab 也会重建
|
||||
const [connectSeq, setConnectSeq] = useState(0)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const connectWebSocket = () => {
|
||||
// 清空旧日志并进入加载态,在触发重连的事件里同步完成,不放在 effect 里
|
||||
const resetLogs = () => {
|
||||
setIsLoading(true)
|
||||
setLogs([])
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
// effect 只负责订阅外部系统:建立连接、把消息写进 state、清理时关闭。
|
||||
// 上一条连接由 cleanup 关闭,不再需要用 ref 手动跟踪。
|
||||
useEffect(() => {
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const server = isDev
|
||||
@@ -103,7 +105,6 @@ export default function LogViewer() {
|
||||
const wsUrl = `${server}/v1/ws/logs?file=${activeTab}.log`
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true)
|
||||
@@ -127,19 +128,20 @@ export default function LogViewer() {
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
connectWebSocket()
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab])
|
||||
}, [activeTab, connectSeq])
|
||||
|
||||
const handleRefresh = () => connectWebSocket()
|
||||
const handleTabChange = (key: string) => {
|
||||
resetLogs()
|
||||
setActiveTab(key)
|
||||
}
|
||||
const handleRefresh = () => {
|
||||
resetLogs()
|
||||
setConnectSeq((n) => n + 1)
|
||||
}
|
||||
const handleClear = () => setLogs([])
|
||||
|
||||
const actions = (
|
||||
@@ -169,7 +171,7 @@ export default function LogViewer() {
|
||||
/>
|
||||
<div className={dc.content}>
|
||||
<div className={dc.card} style={{ padding: 16 }}>
|
||||
<Tabs type="line" activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs type="line" activeKey={activeTab} onChange={handleTabChange}>
|
||||
<TabPane tab="主程序运行日志" itemKey="ds_update">
|
||||
<LogContent logs={logs} logContainerRef={logContainerRef} isLoading={isLoading} />
|
||||
</TabPane>
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
background: var(--semi-color-bg-0);
|
||||
padding: 48px 56px;
|
||||
row-gap: 30px;
|
||||
// 卡片宽度以前由输入框的固定 440px 撑出来(440 + 56×2 = 552),窄于 552 的屏幕上两侧会被
|
||||
// .frame 的 overflow: hidden 裁掉。改为卡片定宽 552 并允许收窄到视口宽度,输入框跟随卡片
|
||||
width: 552px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.component66 {
|
||||
display: inline-flex;
|
||||
@@ -96,7 +105,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-start;
|
||||
// 字段(.semi-form-field)撑满卡片内容区,输入框默认 width: 100% 随之铺满
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
justify-content: flex-start;
|
||||
row-gap: 24px;
|
||||
|
||||
@@ -139,7 +139,6 @@ const Component = () => {
|
||||
label={{ text: "用户名" }}
|
||||
field="username"
|
||||
fieldStyle={{ padding: 0 }}
|
||||
style={{ width: 440 }}
|
||||
className={styles.formField}
|
||||
value={username}
|
||||
initValue='biliup'
|
||||
@@ -151,7 +150,6 @@ const Component = () => {
|
||||
type="password"
|
||||
placeholder={isRegisterMode ? "设置密码" : "输入密码"}
|
||||
fieldStyle={{ padding: 0 }}
|
||||
style={{ width: 440 }}
|
||||
className={styles.formField}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
@@ -163,8 +161,7 @@ const Component = () => {
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
fieldStyle={{ padding: 0 }}
|
||||
style={{ width: 440 }}
|
||||
className={styles.formField}
|
||||
className={styles.formField}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer'
|
||||
import { platformName } from './status'
|
||||
@@ -52,6 +53,38 @@ export function formatDuration(sec: number): string {
|
||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}min`
|
||||
}
|
||||
|
||||
/**
|
||||
* 「当前时间」(Unix 秒)的共享时钟,供渲染期算「已直播多久」这类相对时长。
|
||||
* 渲染函数里直接调 Date.now() 不是纯函数(同一份 props 每次渲染结果都不同,React Compiler 拒绝);
|
||||
* 这里把时间放进一个模块级 store,有订阅者时由定时器每秒推进一次,组件用 useSyncExternalStore 读取。
|
||||
* 首个订阅者接入时先校准一次,避免长时间无人订阅后拿到过期的值。
|
||||
*/
|
||||
const NOW_TICK_MS = 1000
|
||||
let nowSec = Math.floor(Date.now() / 1000)
|
||||
let nowTimer: ReturnType<typeof setInterval> | null = null
|
||||
const nowListeners = new Set<() => void>()
|
||||
const readNowSec = () => nowSec
|
||||
const subscribeNow = (onTick: () => void) => {
|
||||
if (nowListeners.size === 0) {
|
||||
nowSec = Math.floor(Date.now() / 1000)
|
||||
nowTimer = setInterval(() => {
|
||||
nowSec = Math.floor(Date.now() / 1000)
|
||||
nowListeners.forEach((listener) => listener())
|
||||
}, NOW_TICK_MS)
|
||||
}
|
||||
nowListeners.add(onTick)
|
||||
return () => {
|
||||
nowListeners.delete(onTick)
|
||||
if (nowListeners.size === 0 && nowTimer !== null) {
|
||||
clearInterval(nowTimer)
|
||||
nowTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
export function useNowSec(): number {
|
||||
return useSyncExternalStore(subscribeNow, readNowSec, readNowSec)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写盘速率(字节/秒) → "1.2 MB/s" / "680 KB/s"。
|
||||
* 后端没有采样(null / undefined)时返回 null,由调用方显示「—」。
|
||||
|
||||
@@ -18,14 +18,13 @@ export default function useStreamers() {
|
||||
};
|
||||
}
|
||||
|
||||
const NO_USERS: any[] = [];
|
||||
|
||||
export function useBiliUsers() {
|
||||
const {data, error, isLoading} = useSWR<User[]>("/v1/users", fetcher);
|
||||
const [list, setList] = useState<any[]>([]);
|
||||
const [list, setList] = useState<any[]>(NO_USERS);
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) {
|
||||
setList([])
|
||||
return;
|
||||
}
|
||||
if (!data) return;
|
||||
const updateList = async (item: User) => {
|
||||
try {
|
||||
const res = await fetcher(`/v1/users/${item.id}`, undefined);
|
||||
@@ -43,13 +42,16 @@ export function useBiliUsers() {
|
||||
};
|
||||
}
|
||||
};
|
||||
// data 为空数组时这里解析为 [],顺带把上一批账号的补全结果清掉,
|
||||
// 之后再有账号时不会先闪一下旧数据
|
||||
Promise.all(data.map(updateList)).then(setList);
|
||||
}, [data])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError: error,
|
||||
biliUsers: list,
|
||||
// 没有账号时直接派生为空列表,不必等 effect 里再 setState 一轮
|
||||
biliUsers: !data || data.length === 0 ? NO_USERS : list,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+71
-14
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
export const responsiveMap = {
|
||||
xs: '(max-width: 575px)',
|
||||
@@ -58,18 +58,71 @@ export const humDate = (time: number): string =>
|
||||
})
|
||||
.replaceAll('/', '-')
|
||||
|
||||
export const useSystemTheme = () => {
|
||||
const [theme, setTheme] = useState<string>('light')
|
||||
useEffect(() => {
|
||||
const getSystemTheme = () =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
setTheme(getSystemTheme)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => setTheme(getSystemTheme)
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
return theme
|
||||
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
|
||||
const subscribeSystemTheme = (onChange: () => void) => {
|
||||
const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
|
||||
mediaQuery.addEventListener('change', onChange)
|
||||
return () => mediaQuery.removeEventListener('change', onChange)
|
||||
}
|
||||
const getSystemTheme = () => (window.matchMedia(DARK_SCHEME_QUERY).matches ? 'dark' : 'light')
|
||||
const getServerSystemTheme = () => 'light'
|
||||
|
||||
/**
|
||||
* 系统配色(light / dark)。matchMedia 是浏览器侧的外部状态,用 useSyncExternalStore 订阅:
|
||||
* 服务端与水合期固定为 light(与 SSR 输出一致),水合后 React 自行切到真实值,不需要在 effect 里 setState。
|
||||
*/
|
||||
export const useSystemTheme = () =>
|
||||
useSyncExternalStore(subscribeSystemTheme, getSystemTheme, getServerSystemTheme)
|
||||
|
||||
/* ---------- localStorage 偏好(侧栏折叠、主题模式)的订阅式读写 ---------- */
|
||||
|
||||
const storageListeners = new Map<string, Set<() => void>>()
|
||||
/** localStorage 写入失败(配额满、被禁用)时的内存兜底,保证本页内仍能切换,只是刷新后不保留 */
|
||||
const storageFallback = new Map<string, string | null>()
|
||||
|
||||
const readStorage = (key: string): string | null => {
|
||||
if (storageFallback.has(key)) return storageFallback.get(key) ?? null
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const writeStorage = (key: string, value: string | null) => {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, value)
|
||||
storageFallback.delete(key)
|
||||
} catch {
|
||||
storageFallback.set(key, value)
|
||||
}
|
||||
storageListeners.get(key)?.forEach(listener => listener())
|
||||
}
|
||||
const subscribeStorage = (key: string, onChange: () => void) => {
|
||||
let listeners = storageListeners.get(key)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
storageListeners.set(key, listeners)
|
||||
}
|
||||
listeners.add(onChange)
|
||||
return () => {
|
||||
listeners.delete(onChange)
|
||||
}
|
||||
}
|
||||
const getServerStorage = () => null
|
||||
|
||||
/**
|
||||
* 读写 localStorage 里的一个键,并订阅它在本页内的变化。
|
||||
* 替代「useState + 挂载 effect 里 setState」的写法:服务端与水合期快照固定为 null(与 SSR 输出一致),
|
||||
* 水合完成后 React 自己用真实值重渲染一次,既没有水合不一致也没有级联渲染。
|
||||
* 同一个键的写入都要走返回的 setter,订阅者才会收到通知;不要在别处直接改 localStorage 里的这个键。
|
||||
*/
|
||||
export const useLocalStorageValue = (key: string) => {
|
||||
const subscribe = useCallback((onChange: () => void) => subscribeStorage(key, onChange), [key])
|
||||
const getSnapshot = useCallback(() => readStorage(key), [key])
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerStorage)
|
||||
const setValue = useCallback((next: string | null) => writeStorage(key, next), [key])
|
||||
return [value, setValue] as const
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +137,11 @@ export const applyThemeMode = (mode: 'light' | 'dark') => {
|
||||
document.body.setAttribute('theme-mode', mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* mode / systemTheme 变化时把实际主题写到 DOM 上。
|
||||
* mode 的持久化由 useLocalStorageValue('mode') 的 setter 负责,这里不再回写 localStorage:
|
||||
* 两处都写会在 StrictMode 的双次 effect 下用水合期的默认值 auto 把刚读到的已保存主题覆盖掉。
|
||||
*/
|
||||
export const useTheme = (mode: string, systemTheme: string) => {
|
||||
const firstRun = useRef(true)
|
||||
useEffect(() => {
|
||||
@@ -93,7 +151,6 @@ export const useTheme = (mode: string, systemTheme: string) => {
|
||||
firstRun.current = false
|
||||
return
|
||||
}
|
||||
localStorage.setItem('mode', mode)
|
||||
const actualMode = (mode === 'auto' ? systemTheme : mode) as 'light' | 'dark'
|
||||
applyThemeMode(actualMode)
|
||||
}, [mode, systemTheme])
|
||||
|
||||
+14
-16
@@ -47,6 +47,12 @@ const removeCircularReferences = (obj: any, seen = new WeakSet()): any => {
|
||||
return result
|
||||
}
|
||||
|
||||
type PlatformPattern = keyof typeof SupportedPlatforms
|
||||
|
||||
/** 按直播间地址找到对应平台插件在 SupportedPlatforms 里的键;没匹配到返回 undefined */
|
||||
const matchPlatformPattern = (url?: string): PlatformPattern | undefined =>
|
||||
(Object.keys(SupportedPlatforms) as PlatformPattern[]).find(pattern => url?.match(new RegExp(pattern)))
|
||||
|
||||
const OverrideModal: React.FC<TemplateModalProps> = ({ children, entity, onOk }) => {
|
||||
const [isOpen, setOpen] = useState(false)
|
||||
|
||||
@@ -54,16 +60,11 @@ const OverrideModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
const platformSetting = () => {
|
||||
for (const [pattern, Plugin] of Object.entries(SupportedPlatforms)) {
|
||||
if (entity?.url.match(new RegExp(pattern))) {
|
||||
// console.log('匹配到平台:', pattern)
|
||||
return Plugin as React.ComponentType<PluginProps>
|
||||
}
|
||||
}
|
||||
// console.log('未匹配到平台')
|
||||
return null
|
||||
}
|
||||
// 平台插件组件从模块级常量表里按键取出,渲染间始终是同一个引用,不会因为重渲染而重置内部状态
|
||||
const platformPattern = matchPlatformPattern(entity?.url)
|
||||
const PlatformPlugin = platformPattern
|
||||
? (SupportedPlatforms[platformPattern] as React.ComponentType<PluginProps>)
|
||||
: null
|
||||
|
||||
const api = useRef<FormApi>(undefined)
|
||||
|
||||
@@ -291,12 +292,9 @@ const OverrideModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
|
||||
<Form.Section>
|
||||
<Collapse defaultActiveKey={['plugin']}>
|
||||
{downloadSettings}
|
||||
{(() => {
|
||||
const Plugin = platformSetting()
|
||||
return Plugin ? (
|
||||
<Plugin entity={entity} list={list} initValues={entity?.override} />
|
||||
) : null
|
||||
})()}
|
||||
{PlatformPlugin ? (
|
||||
<PlatformPlugin entity={entity} list={list} initValues={entity?.override} />
|
||||
) : null}
|
||||
</Collapse>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
+11
-7
@@ -3,7 +3,7 @@ import React, { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
|
||||
import { streamerStatusMeta, uploadStatusTag, platformName } from '@/app/lib/status'
|
||||
import { timeAgo, formatDuration, formatRate, liveImageUrl } from '@/app/lib/use-dashboard'
|
||||
import { timeAgo, formatDuration, formatRate, liveImageUrl, useNowSec } from '@/app/lib/use-dashboard'
|
||||
import styles from './streamer-card.module.scss'
|
||||
|
||||
export interface StreamerCardProps {
|
||||
@@ -77,6 +77,15 @@ export function LiveAvatar({ src }: { src: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 直播中的实时时长:由"直播开始时间"和共享时钟算出。
|
||||
* 只有录制中的卡片挂载它,所以只有这些卡片会随时钟每秒重渲染这一小块,其余卡片不受影响。
|
||||
*/
|
||||
function LiveDuration({ since }: { since: number }) {
|
||||
const now = useNowSec()
|
||||
return <span className={styles.liveInfo}>{formatDuration(now - since)}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局统一的直播间卡片:
|
||||
* 状态徽章 + 平台色 chip + [录制中:封面 / 头像 / 写盘速率] + 名称 + 实时时长(直播中)/ 最近录制(未开播)
|
||||
@@ -95,11 +104,6 @@ export default function StreamerCard({
|
||||
const paused = streamer.status === 'Pause'
|
||||
const name = streamer.remark || streamer.url
|
||||
|
||||
// 实时时长:直播中时由"直播开始时间"计算
|
||||
let duration: string | null = null
|
||||
if (live && info?.date) {
|
||||
duration = formatDuration(Date.now() / 1000 - info.date)
|
||||
}
|
||||
const lastRec = info?.date && !live ? timeAgo(info.date) : null
|
||||
|
||||
// 录制中才有封面 / 头像 / 速率;图片走同源代理,避开图片 CDN 的 Referer 校验
|
||||
@@ -160,7 +164,7 @@ export default function StreamerCard({
|
||||
</div>
|
||||
{live ? (
|
||||
<span className={styles.liveStats}>
|
||||
{duration ? <span className={styles.liveInfo}>{duration}</span> : null}
|
||||
{info?.date ? <LiveDuration since={info.date} /> : null}
|
||||
<span
|
||||
className={styles.rate}
|
||||
title={rate ? '写盘速率(最近 10 秒平均)' : '写盘速率:尚无采样'}
|
||||
|
||||
+17
-15
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { FormFCChild } from '@douyinfe/semi-ui/lib/es/form'
|
||||
import {
|
||||
IconChevronDown,
|
||||
@@ -25,6 +25,18 @@ import useSWR from 'swr'
|
||||
import { BiliType, fetcher, StudioEntity } from '../lib/api-streamer'
|
||||
import { useBiliUsers, useTypeTree } from '../lib/use-streamers'
|
||||
|
||||
/** 把已保存的定时发布延迟 dtime(秒)换算成滚轮的「小时 / 分钟」;没有或不在可选范围内时用默认的 4 小时 0 分 */
|
||||
function delayFromDtime(dtime: any): { hours: number; minutes: number } {
|
||||
if (dtime) {
|
||||
const hours = Math.floor(dtime / 3600)
|
||||
const minutes = Math.floor((dtime % 3600) / 60)
|
||||
if (hours >= 4 && hours < 24 * 15) {
|
||||
return { hours, minutes: Math.floor(minutes / 5) * 5 }
|
||||
}
|
||||
}
|
||||
return { hours: 4, minutes: 0 }
|
||||
}
|
||||
|
||||
const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>> = ({
|
||||
formState,
|
||||
formApi,
|
||||
@@ -171,20 +183,10 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
|
||||
text: `${i * 5}分钟`,
|
||||
}
|
||||
})
|
||||
const [selectedHours, setSelectedHours] = useState(4)
|
||||
const [selectedMinutes, setSelectedMinutes] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const dtime = formApi.getValue('dtime')
|
||||
if (dtime) {
|
||||
const hours = Math.floor(dtime / 3600)
|
||||
const minutes = Math.floor((dtime % 3600) / 60)
|
||||
if (hours >= 4 && hours < 24 * 15) {
|
||||
setSelectedHours(hours)
|
||||
setSelectedMinutes(Math.floor(minutes / 5) * 5)
|
||||
}
|
||||
}
|
||||
}, [formApi])
|
||||
// 滚轮的初始选中值取自表单里已有的 dtime(编辑模板时)。Semi Form 在构造时就把 initValues 放进
|
||||
// store,首次渲染即可读到,直接作为 useState 初始值,不必挂载后在 effect 里再 setState 一轮
|
||||
const [selectedHours, setSelectedHours] = useState(() => delayFromDtime(formApi.getValue('dtime')).hours)
|
||||
const [selectedMinutes, setSelectedMinutes] = useState(() => delayFromDtime(formApi.getValue('dtime')).minutes)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@douyinfe/semi-ui'
|
||||
import { IconPlusCircle, IconMinusCircle } from '@douyinfe/semi-icons'
|
||||
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
|
||||
import React, { CSSProperties, useRef } from 'react'
|
||||
import React, { CSSProperties, useMemo, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { fetcher, LiveStreamerEntity, sendRequest, StudioEntity } from '../lib/api-streamer'
|
||||
import useSWR from 'swr'
|
||||
@@ -115,14 +115,19 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
if (entity && entity.time_range && typeof entity.time_range === "string") {
|
||||
const tr: string[] = JSON.parse(entity.time_range)
|
||||
entity.time_range = tr.map(t => new Date(t))
|
||||
// 后端把 time_range 存成 JSON 字串,TimePicker 需要 Date[]。entity 是上层传下来的 props,
|
||||
// 渲染期不能就地改写,转换结果放进一份本地副本作为表单初始值;解析失败时保持原样(与之前一致)
|
||||
const initValues = useMemo(() => {
|
||||
if (entity && entity.time_range && typeof entity.time_range === 'string') {
|
||||
try {
|
||||
const tr: string[] = JSON.parse(entity.time_range)
|
||||
return { ...entity, time_range: tr.map(t => new Date(t)) }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
return entity
|
||||
}, [entity])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,7 +145,7 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
|
||||
paddingRight: 10,
|
||||
}}
|
||||
>
|
||||
<Form initValues={entity} getFormApi={formApi => (api.current = formApi)}>
|
||||
<Form initValues={initValues} getFormApi={formApi => (api.current = formApi)}>
|
||||
<Form.Input
|
||||
field="remark"
|
||||
label="录播备注"
|
||||
|
||||
+18
-37
@@ -1,53 +1,34 @@
|
||||
'use client'
|
||||
import { SetStateAction, useEffect, useState } from 'react'
|
||||
import { Button } from '@douyinfe/semi-ui'
|
||||
import { IconMoon, IconSun, IconContrast } from '@douyinfe/semi-icons'
|
||||
import { applyThemeMode } from '../lib/utils'
|
||||
|
||||
interface ThemeButtonProps {
|
||||
mode: string
|
||||
setMode: {
|
||||
(value: SetStateAction<string>): void
|
||||
(arg0: string): void
|
||||
}
|
||||
setMode: (mode: string) => void
|
||||
systemTheme: string
|
||||
}
|
||||
|
||||
const ThemeButton: React.FC<ThemeButtonProps> = props => {
|
||||
const [switchTrigger, setSwitchTrigger] = useState(false)
|
||||
const [icon, setIcon] = useState(<IconContrast size="large" />)
|
||||
useEffect(() => {
|
||||
{
|
||||
// 按下按钮切换主题:一键明暗反转,避免 auto→light→dark 循环的第一下视觉无变化。
|
||||
if (typeof window !== 'undefined' && switchTrigger === true) {
|
||||
const currentMode = props.mode
|
||||
const isDarkNow =
|
||||
currentMode === 'dark' ||
|
||||
(currentMode === 'auto' && props.systemTheme === 'dark')
|
||||
const nextMode = isDarkNow ? 'light' : 'dark'
|
||||
applyThemeMode(nextMode)
|
||||
props.setMode(nextMode)
|
||||
setSwitchTrigger(false)
|
||||
}
|
||||
// 更新图标
|
||||
switch (props.mode) {
|
||||
case 'light':
|
||||
setIcon(<IconSun size="large" />)
|
||||
break
|
||||
case 'dark':
|
||||
setIcon(<IconMoon size="large" />)
|
||||
break
|
||||
default:
|
||||
setIcon(<IconContrast size="large" />)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [props, switchTrigger])
|
||||
|
||||
const ThemeButton: React.FC<ThemeButtonProps> = ({ mode, setMode, systemTheme }) => {
|
||||
// 按下按钮切换主题:一键明暗反转,避免 auto→light→dark 循环的第一下视觉无变化。
|
||||
// 直接在事件里完成,不再经由「置标志位 → effect 里读标志位再 setState」绕一圈。
|
||||
const switchMode = () => {
|
||||
setSwitchTrigger(true)
|
||||
const isDarkNow = mode === 'dark' || (mode === 'auto' && systemTheme === 'dark')
|
||||
const nextMode = isDarkNow ? 'light' : 'dark'
|
||||
applyThemeMode(nextMode)
|
||||
setMode(nextMode)
|
||||
}
|
||||
|
||||
// 图标由 mode 直接派生,不需要单独的 state
|
||||
const icon =
|
||||
mode === 'light' ? (
|
||||
<IconSun size="large" />
|
||||
) : mode === 'dark' ? (
|
||||
<IconMoon size="large" />
|
||||
) : (
|
||||
<IconContrast size="large" />
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={switchMode}
|
||||
|
||||
Reference in new issue
Block a user