diff --git a/app/(app)/components/BackgroundSetter.tsx b/app/(app)/components/BackgroundSetter.tsx index c347e83..6d88e2a 100644 --- a/app/(app)/components/BackgroundSetter.tsx +++ b/app/(app)/components/BackgroundSetter.tsx @@ -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 { export default function BackgroundSetter() { const fileRef = useRef(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) => { const file = e.target.files?.[0] diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index baaed9b..9dec81e 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -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(null) + // 切面板的 state 更新用 flushSync 同步提交,之后字段已可见,直接滚动、聚焦即可, + // 不再需要「记一个待定位字段 → effect 里滚动再清掉」的中间 state。 const handleSubmitFail = (errors: Record) => { 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('[data-tab]')?.dataset.tab const platform = first.el.closest('[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('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('input, textarea')?.focus({ preventScroll: true }) - setPendingField(null) - }, [pendingField]) if (isLoading) { return <>Loading diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index b30cdc1..76cb9ca 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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('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) diff --git a/app/(app)/logviewer/page.tsx b/app/(app)/logviewer/page.tsx index 7a7004f..69977e5 100644 --- a/app/(app)/logviewer/page.tsx +++ b/app/(app)/logviewer/page.tsx @@ -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(null) + // 每加一就重建一次 WebSocket(「刷新」按钮);切换 Tab 也会重建 + const [connectSeq, setConnectSeq] = useState(0) const logContainerRef = useRef(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() { />
- + diff --git a/app/(auth)/login/index.module.scss b/app/(auth)/login/index.module.scss index 4964b66..6fa13cf 100644 --- a/app/(auth)/login/index.module.scss +++ b/app/(auth)/login/index.module.scss @@ -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; diff --git a/app/(auth)/login/page.jsx b/app/(auth)/login/page.jsx index cda89f5..ec30f70 100644 --- a/app/(auth)/login/page.jsx +++ b/app/(auth)/login/page.jsx @@ -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: '请再次输入密码' }, { diff --git a/app/lib/use-dashboard.ts b/app/lib/use-dashboard.ts index 7009c4e..6fb4496 100644 --- a/app/lib/use-dashboard.ts +++ b/app/lib/use-dashboard.ts @@ -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 | 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,由调用方显示「—」。 diff --git a/app/lib/use-streamers.ts b/app/lib/use-streamers.ts index 88ce083..681d529 100644 --- a/app/lib/use-streamers.ts +++ b/app/lib/use-streamers.ts @@ -18,14 +18,13 @@ export default function useStreamers() { }; } +const NO_USERS: any[] = []; + export function useBiliUsers() { const {data, error, isLoading} = useSWR("/v1/users", fetcher); - const [list, setList] = useState([]); + const [list, setList] = useState(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, }; } diff --git a/app/lib/utils/index.tsx b/app/lib/utils/index.tsx index 9512e75..d6f99e9 100644 --- a/app/lib/utils/index.tsx +++ b/app/lib/utils/index.tsx @@ -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('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 void>>() +/** localStorage 写入失败(配额满、被禁用)时的内存兜底,保证本页内仍能切换,只是刷新后不保留 */ +const storageFallback = new Map() + +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]) diff --git a/app/ui/OverrideModal.tsx b/app/ui/OverrideModal.tsx index 712dbbb..6157fbf 100644 --- a/app/ui/OverrideModal.tsx +++ b/app/ui/OverrideModal.tsx @@ -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 = ({ children, entity, onOk }) => { const [isOpen, setOpen] = useState(false) @@ -54,16 +60,11 @@ const OverrideModal: React.FC = ({ 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 - } - } - // console.log('未匹配到平台') - return null - } + // 平台插件组件从模块级常量表里按键取出,渲染间始终是同一个引用,不会因为重渲染而重置内部状态 + const platformPattern = matchPlatformPattern(entity?.url) + const PlatformPlugin = platformPattern + ? (SupportedPlatforms[platformPattern] as React.ComponentType) + : null const api = useRef(undefined) @@ -291,12 +292,9 @@ const OverrideModal: React.FC = ({ children, entity, onOk }) {downloadSettings} - {(() => { - const Plugin = platformSetting() - return Plugin ? ( - - ) : null - })()} + {PlatformPlugin ? ( + + ) : null} diff --git a/app/ui/StreamerCard.tsx b/app/ui/StreamerCard.tsx index 6c90938..bc66e53 100644 --- a/app/ui/StreamerCard.tsx +++ b/app/ui/StreamerCard.tsx @@ -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 {formatDuration(now - since)} +} + /** * 全局统一的直播间卡片: * 状态徽章 + 平台色 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({
{live ? ( - {duration ? {duration} : null} + {info?.date ? : null} = 4 && hours < 24 * 15) { + return { hours, minutes: Math.floor(minutes / 5) * 5 } + } + } + return { hours: 4, minutes: 0 } +} + const TemplateFields: React.FC> = ({ formState, formApi, @@ -171,20 +183,10 @@ const TemplateFields: React.FC> 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 ( <> diff --git a/app/ui/TemplateModal.tsx b/app/ui/TemplateModal.tsx index 2e0c83e..63aa93e 100644 --- a/app/ui/TemplateModal.tsx +++ b/app/ui/TemplateModal.tsx @@ -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 = ({ 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 = ({ children, entity, onOk }) paddingRight: 10, }} > -
(api.current = formApi)}> + (api.current = formApi)}> ): void - (arg0: string): void - } + setMode: (mode: string) => void systemTheme: string } -const ThemeButton: React.FC = props => { - const [switchTrigger, setSwitchTrigger] = useState(false) - const [icon, setIcon] = useState() - 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() - break - case 'dark': - setIcon() - break - default: - setIcon() - break - } - } - }, [props, switchTrigger]) - +const ThemeButton: React.FC = ({ 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' ? ( + + ) : mode === 'dark' ? ( + + ) : ( + + ) + return (