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:
Noah authored and GitHub committed 2026-09-22 08:27:12 +00:00
1 parent e988af8268
commit 20fe058b15
15 files changed
+241 -184

No files matched your search

+5 -8
View File
@@ -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]
+10 -12
View File
@@ -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
View File
@@ -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)
+19 -17
View File
@@ -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>
+11 -1
View File
@@ -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;
+1 -4
View File
@@ -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: '请再次输入密码' },
{
+33
View File
@@ -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,由调用方显示「—」。
+8 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 (
<>
+14 -9
View File
@@ -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
View File
@@ -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}