mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 14:31:35 +08:00
refactor(miniapp): 适配顶部状态栏 (#9695)
* feat(minapp): add top-navbar fixed toolbar and layout adjustments * refactor(minapps): optimize toolbar * fix(minapps): hide redundant components * feat(minapp): improve webview load handling and popup visibility * feat(minapps): improve WebView load handling and clean up launchpad * feat(minapp): 实现活跃小程序数量限制与关闭缓存清理 * fix(minapp): 修复WebView高度不正确的问题 * fix(minapp): show popup only for left navbar mode * feat(minapps): add full-screen loading mask for webview * fix: lint error * feat(minapp): fix drawer sizing and layout when side navbar present * refactor(minapp): 移除固定工具栏组件,优化弹窗容器布局 * feat(minapps): memoize app lookup to avoid unnecessary recompute * chore(minapps): optimize comments Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(renderer): remove stray blank line in MinAppFullPageView * refactor(minapps): remove top navbar opened minapps component * refactor(tab): remove unused TopNavbarOpenedMinappTabs import --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
b647017c43
commit
935189f3f7
@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
|
||||
import MinAppPage from './pages/minapps/MinAppPage'
|
||||
import MinAppsPage from './pages/minapps/MinAppsPage'
|
||||
import NotesPage from './pages/notes/NotesPage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
@ -34,6 +35,7 @@ const Router: FC = () => {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/notes" element={<NotesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps/:appId" element={<MinAppPage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
|
||||
@ -13,6 +13,7 @@ import { Dropdown } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@ -30,6 +31,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
|
||||
const dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const isVisible = minapps.some((m) => m.id === app.id)
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
@ -37,7 +39,13 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
const handleClick = () => {
|
||||
openMinappKeepAlive(app)
|
||||
if (isTopNavbar) {
|
||||
// 顶部导航栏:导航到小程序页面
|
||||
navigate(`/apps/${app.id}`)
|
||||
} else {
|
||||
// 侧边导航栏:保持原有弹窗行为
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -162,8 +163,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
/** store the webview refs, one of the key to make them keepalive */
|
||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||
/** indicate whether the webview has loaded */
|
||||
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
||||
/** Note: WebView loaded states now managed globally via webviewStateManager */
|
||||
/** whether the minapps open link external is enabled */
|
||||
const { minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
setIsPopupShow(true)
|
||||
|
||||
if (webviewLoadedRefs.current.get(currentMinappId)) {
|
||||
if (getWebviewLoaded(currentMinappId)) {
|
||||
setIsReady(true)
|
||||
/** the case that open the minapp from sidebar */
|
||||
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
||||
@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => {
|
||||
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
||||
})
|
||||
|
||||
//delete the extra webviewLoadedRefs
|
||||
webviewLoadedRefs.current.forEach((_, appid) => {
|
||||
if (!webviewRefs.current.has(appid)) {
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
} else if (appid === currentMinappId) {
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
// Set external link behavior for current minapp
|
||||
if (currentMinappId) {
|
||||
const webviewElement = webviewRefs.current.get(currentMinappId)
|
||||
if (webviewElement) {
|
||||
try {
|
||||
const webviewId = webviewElement.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
} catch (error) {
|
||||
// WebView not ready yet, will be set when it's loaded
|
||||
logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [currentMinappId, minappsOpenLinkExternal])
|
||||
|
||||
/** only the keepalive minapp can be minimized */
|
||||
@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** get the current app info with extra info */
|
||||
let currentAppInfo: AppInfo | null = null
|
||||
if (currentMinappId) {
|
||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
|
||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
|
||||
if (currentApp) {
|
||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||
}
|
||||
}
|
||||
|
||||
/** will close the popup and delete the webview */
|
||||
const handlePopupClose = async (appid: string) => {
|
||||
setIsPopupShow(false)
|
||||
await delay(0.3)
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
clearWebviewState(appid)
|
||||
closeMinapp(appid)
|
||||
}
|
||||
|
||||
@ -292,10 +298,17 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
/** the callback function to set the webviews loaded indicator */
|
||||
const handleWebviewLoaded = (appid: string) => {
|
||||
webviewLoadedRefs.current.set(appid, true)
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
setWebviewLoaded(appid, true)
|
||||
const webviewElement = webviewRefs.current.get(appid)
|
||||
if (webviewElement) {
|
||||
try {
|
||||
const webviewId = webviewElement.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`)
|
||||
}
|
||||
}
|
||||
if (appid == currentMinappId) {
|
||||
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
|
||||
@ -352,16 +365,28 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** navigate back in webview history */
|
||||
const handleGoBack = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview && webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
if (webview) {
|
||||
try {
|
||||
if (webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for goBack()`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** navigate forward in webview history */
|
||||
const handleGoForward = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview && webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
if (webview) {
|
||||
try {
|
||||
if (webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for goForward()`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -409,7 +434,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
@ -498,19 +523,23 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||
placement="bottom"
|
||||
onClose={handlePopupMinimize}
|
||||
open={isPopupShow}
|
||||
mask={false}
|
||||
rootClassName="minapp-drawer"
|
||||
maskClassName="minapp-mask"
|
||||
height={'100%'}
|
||||
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{
|
||||
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
||||
backgroundColor: window.root.style.background
|
||||
styles={{
|
||||
wrapper: {
|
||||
position: 'fixed',
|
||||
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
||||
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0,
|
||||
backgroundColor: window.root.style.background
|
||||
}
|
||||
}}>
|
||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||
@ -566,7 +595,7 @@ const TitleTextTooltip = styled.span`
|
||||
}
|
||||
`
|
||||
|
||||
const ButtonsGroup = styled.div`
|
||||
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
|
||||
const TopViewMinappContainer = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
|
||||
|
||||
return <>{isCreate && <MinappPopupContainer />}</>
|
||||
// Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar)
|
||||
return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</>
|
||||
}
|
||||
|
||||
export default TopViewMinappContainer
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { loggerService } from '@logger'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
const logger = loggerService.withContext('WebviewContainer')
|
||||
|
||||
/**
|
||||
* WebviewContainer is a component that renders a webview element.
|
||||
* It is used in the MinAppPopupContainer component.
|
||||
@ -23,7 +26,6 @@ const WebviewContainer = memo(
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@ -41,8 +43,29 @@ const WebviewContainer = memo(
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return
|
||||
|
||||
let loadCallbackFired = false
|
||||
|
||||
const handleLoaded = () => {
|
||||
onLoadedCallback(appid)
|
||||
logger.debug(`WebView did-finish-load for app: ${appid}`)
|
||||
// Only fire callback once per load cycle
|
||||
if (!loadCallbackFired) {
|
||||
loadCallbackFired = true
|
||||
// Small delay to ensure content is actually visible
|
||||
setTimeout(() => {
|
||||
logger.debug(`Calling onLoadedCallback for app: ${appid}`)
|
||||
onLoadedCallback(appid)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional callback for when page is ready to show
|
||||
const handleReadyToShow = () => {
|
||||
logger.debug(`WebView ready-to-show for app: ${appid}`)
|
||||
if (!loadCallbackFired) {
|
||||
loadCallbackFired = true
|
||||
logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`)
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (event: any) => {
|
||||
@ -56,16 +79,25 @@ const WebviewContainer = memo(
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartLoading = () => {
|
||||
// Reset callback flag when starting a new load
|
||||
loadCallbackFired = false
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('did-start-loading', handleStartLoading)
|
||||
webviewRef.current.addEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
// we set the url when the webview is ready
|
||||
webviewRef.current.src = url
|
||||
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
|
||||
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
@ -73,8 +105,8 @@ const WebviewContainer = memo(
|
||||
}, [appid, url])
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
@ -37,11 +38,22 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
|
||||
// Check if it's a minapp tab (format: apps:appId)
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
if (app) {
|
||||
return <MinAppIcon size={14} app={app} />
|
||||
}
|
||||
}
|
||||
|
||||
switch (tabId) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
@ -82,6 +94,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
@ -89,9 +102,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
// Handle minapp paths: /apps/appId -> apps:appId
|
||||
if (segments[1] === 'apps' && segments[2]) {
|
||||
return `apps:${segments[2]}`
|
||||
}
|
||||
return segments[1] // 获取第一个路径段作为 id
|
||||
}
|
||||
|
||||
const getTabTitle = (tabId: string): string => {
|
||||
// Check if it's a minapp tab
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
return app ? app.name : 'MinApp'
|
||||
}
|
||||
return getTitleLabel(tabId)
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
if (path === '/settings') return false
|
||||
@ -196,8 +223,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
@ -224,7 +251,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
</AddTabButton>
|
||||
</TabsArea>
|
||||
<RightButtonsContainer>
|
||||
<TopNavbarOpenedMinappTabs />
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
||||
mouseEnterDelay={0.8}
|
||||
|
||||
@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
|
||||
/** Tabs of opened minapps in top navbar */
|
||||
export const TopNavbarOpenedMinappTabs: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
|
||||
const { showOpenedMinappsInSidebar } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [openedKeepAliveMinapps])
|
||||
|
||||
// animation for minapp switch indicator
|
||||
useEffect(() => {
|
||||
const iconDefaultWidth = 30 // 22px icon + 8px gap
|
||||
const iconDefaultOffset = 10 // initial offset
|
||||
const container = document.querySelector('.TopNavContainer') as HTMLElement
|
||||
const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement
|
||||
|
||||
let indicatorLeft = 0,
|
||||
indicatorBottom = 0
|
||||
if (minappShow && activeIcon && container) {
|
||||
indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px)
|
||||
indicatorBottom = 0
|
||||
} else {
|
||||
indicatorLeft =
|
||||
((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4
|
||||
indicatorBottom = -50
|
||||
}
|
||||
container?.style.setProperty('--indicator-left', `${indicatorLeft}px`)
|
||||
container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`)
|
||||
}, [currentMinappId, keepAliveMinapps, minappShow])
|
||||
|
||||
const handleOnClick = (app: MinAppType) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要显示已打开小程序组件
|
||||
const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0
|
||||
|
||||
// 如果不需要显示,返回空容器
|
||||
if (!isShowOpened) return null
|
||||
|
||||
return (
|
||||
<TopNavContainer
|
||||
className="TopNavContainer"
|
||||
style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}>
|
||||
<TopNavMenus>
|
||||
{keepAliveMinapps.map((app) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'closeApp',
|
||||
label: t('minapp.sidebar.close.title'),
|
||||
onClick: () => {
|
||||
closeMinapp(app.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'closeAllApp',
|
||||
label: t('minapp.sidebar.closeall.title'),
|
||||
onClick: () => {
|
||||
closeAllMinapps()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
|
||||
return (
|
||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||
<TopNavItemContainer
|
||||
onClick={() => handleOnClick(app)}
|
||||
theme={theme}
|
||||
className={`${isActive ? 'opened-active' : ''}`}>
|
||||
<TopNavIcon theme={theme}>
|
||||
<MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} />
|
||||
</TopNavIcon>
|
||||
</TopNavItemContainer>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</TopNavMenus>
|
||||
</TopNavContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabs of opened minapps in sidebar */
|
||||
export const SidebarOpenedMinappTabs: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
|
||||
const handleOnClick = (app) => {
|
||||
const handleOnClick = (app: MinAppType) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
@ -329,50 +235,3 @@ const TabsWrapper = styled.div`
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const TopNavContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
gap: 4px;
|
||||
background-color: var(--color-list-item);
|
||||
border-radius: 20px;
|
||||
margin: 0 5px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--indicator-left, 0);
|
||||
bottom: var(--indicator-bottom, 0);
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
background-color: var(--color-primary);
|
||||
transition:
|
||||
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
bottom 0.3s ease-in-out;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`
|
||||
|
||||
const TopNavMenus = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const TopNavIcon = styled(Icon)`
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
`
|
||||
|
||||
const TopNavItemContainer = styled.div`
|
||||
display: flex;
|
||||
transition: border 0.2s ease;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
`
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCurrentMinappId,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
setOpenedOneOffMinapp
|
||||
} from '@renderer/store/runtime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
@ -36,7 +38,18 @@ export const useMinappPopup = () => {
|
||||
const createLRUCache = useCallback(() => {
|
||||
return new LRUCache<string, MinAppType>({
|
||||
max: maxKeepAliveMinapps,
|
||||
disposeAfter: () => {
|
||||
disposeAfter: (_value, key) => {
|
||||
// Clean up WebView state when app is disposed from cache
|
||||
clearWebviewState(key)
|
||||
|
||||
// Close corresponding tab if it exists
|
||||
const tabs = TabsService.getTabs()
|
||||
const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`)
|
||||
if (tabToClose) {
|
||||
TabsService.closeTab(tabToClose.id)
|
||||
}
|
||||
|
||||
// Update Redux state
|
||||
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
|
||||
},
|
||||
onInsert: () => {
|
||||
@ -158,6 +171,8 @@ export const useMinappPopup = () => {
|
||||
openMinappById,
|
||||
closeMinapp,
|
||||
hideMinappPopup,
|
||||
closeAllMinapps
|
||||
closeAllMinapps,
|
||||
// Expose cache instance for TabsService integration
|
||||
minAppsCache
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import App from '@renderer/components/MinApp/MinApp'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -105,7 +104,7 @@ const LaunchpadPage: FC = () => {
|
||||
<Grid>
|
||||
{sortedMinapps.map((app) => (
|
||||
<AppWrapper key={app.id}>
|
||||
<App app={app} size={56} onClick={() => setTimeout(() => tabsService.closeTab('launchpad'), 350)} />
|
||||
<App app={app} size={56} />
|
||||
</AppWrapper>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
102
src/renderer/src/pages/minapps/MinAppPage.tsx
Normal file
102
src/renderer/src/pages/minapps/MinAppPage.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinAppFullPageView from './components/MinAppFullPageView'
|
||||
|
||||
const logger = loggerService.withContext('MinAppPage')
|
||||
|
||||
const MinAppPage: FC = () => {
|
||||
const { appId } = useParams<{ appId: string }>()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
const { openMinappKeepAlive, minAppsCache } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { openedKeepAliveMinapps } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Remember the initial navbar position when component mounts
|
||||
const initialIsTopNavbar = useRef<boolean>(isTopNavbar)
|
||||
const hasRedirected = useRef<boolean>(false)
|
||||
|
||||
// Initialize TabsService with cache reference
|
||||
useEffect(() => {
|
||||
if (minAppsCache) {
|
||||
TabsService.setMinAppsCache(minAppsCache)
|
||||
}
|
||||
}, [minAppsCache])
|
||||
|
||||
// Debug: track navbar position changes
|
||||
useEffect(() => {
|
||||
if (initialIsTopNavbar.current !== isTopNavbar) {
|
||||
logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`)
|
||||
}
|
||||
}, [isTopNavbar])
|
||||
|
||||
// Find the app from all available apps
|
||||
const app = useMemo(() => {
|
||||
if (!appId) return null
|
||||
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
}, [appId, minapps])
|
||||
|
||||
useEffect(() => {
|
||||
// If app not found, redirect to apps list
|
||||
if (!app) {
|
||||
navigate('/apps')
|
||||
return
|
||||
}
|
||||
|
||||
// For sidebar navigation, redirect to apps list and open popup
|
||||
// Only check once and only if we haven't already redirected
|
||||
if (!initialIsTopNavbar.current && !hasRedirected.current) {
|
||||
hasRedirected.current = true
|
||||
navigate('/apps')
|
||||
// Open popup after navigation
|
||||
setTimeout(() => {
|
||||
openMinappKeepAlive(app)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// For top navbar mode, integrate with cache system
|
||||
if (initialIsTopNavbar.current) {
|
||||
// Check if app is already in the keep-alive cache
|
||||
const isAlreadyInCache = openedKeepAliveMinapps.some((cachedApp) => cachedApp.id === app.id)
|
||||
|
||||
if (!isAlreadyInCache) {
|
||||
logger.debug(`Adding app ${app.id} to keep-alive cache via openMinappKeepAlive`)
|
||||
// Add to cache without showing popup (for tab mode)
|
||||
openMinappKeepAlive(app)
|
||||
} else {
|
||||
logger.debug(`App ${app.id} already in keep-alive cache`)
|
||||
}
|
||||
}
|
||||
}, [app, navigate, openMinappKeepAlive, openedKeepAliveMinapps, initialIsTopNavbar])
|
||||
|
||||
// Don't render anything if app not found or not in top navbar mode initially
|
||||
if (!app || !initialIsTopNavbar.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MinAppFullPageView app={app} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default MinAppPage
|
||||
166
src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx
Normal file
166
src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { loggerService } from '@logger'
|
||||
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||
import { Avatar } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinimalToolbar from './MinimalToolbar'
|
||||
|
||||
const logger = loggerService.withContext('MinAppFullPageView')
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
}
|
||||
|
||||
const MinAppFullPageView: FC<Props> = ({ app }) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
|
||||
const { minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
// Debug: log isReady state changes
|
||||
useEffect(() => {
|
||||
logger.debug(`isReady state changed to: ${isReady}`)
|
||||
}, [isReady])
|
||||
|
||||
// Initialize when app changes - smart loading state detection using global state
|
||||
useEffect(() => {
|
||||
setCurrentUrl(app.url)
|
||||
|
||||
// Check if this WebView has been loaded before using global state manager
|
||||
if (getWebviewLoaded(app.id)) {
|
||||
logger.debug(`App ${app.id} already loaded before, setting ready immediately`)
|
||||
setIsReady(true)
|
||||
return // No cleanup needed for immediate ready state
|
||||
} else {
|
||||
logger.debug(`App ${app.id} not loaded before, showing loading state`)
|
||||
setIsReady(false)
|
||||
|
||||
// Backup timer logic removed as requested—loading animation will show indefinitely if needed.
|
||||
// (See version control history for previous implementation.)
|
||||
}
|
||||
}, [app])
|
||||
|
||||
const handleWebviewSetRef = useCallback((_appId: string, element: WebviewTag | null) => {
|
||||
webviewRef.current = element
|
||||
if (element) {
|
||||
logger.debug('WebView element set')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWebviewLoaded = useCallback(
|
||||
(appId: string) => {
|
||||
logger.debug(`WebView loaded for app: ${appId}`)
|
||||
const webviewId = webviewRef.current?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
|
||||
// Mark this WebView as loaded for future use in global state
|
||||
setWebviewLoaded(appId, true)
|
||||
|
||||
// Use small delay like MinappPopupContainer (100ms) to ensure content is visible
|
||||
if (appId === app.id) {
|
||||
setTimeout(() => {
|
||||
logger.debug(`WebView loaded callback: setting isReady to true for ${appId}`)
|
||||
setIsReady(true)
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
[minappsOpenLinkExternal, app.id]
|
||||
)
|
||||
|
||||
const handleWebviewNavigate = useCallback((_appId: string, url: string) => {
|
||||
logger.debug(`URL changed: ${url}`)
|
||||
setCurrentUrl(url)
|
||||
}, [])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (webviewRef.current) {
|
||||
// Clear the loaded state for this app since we're reloading using global state
|
||||
setWebviewLoaded(app.id, false)
|
||||
setIsReady(false) // Set loading state when reloading
|
||||
webviewRef.current.src = app.url
|
||||
}
|
||||
}, [app.url, app.id])
|
||||
|
||||
const handleOpenDevTools = useCallback(() => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.openDevTools()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MinimalToolbar
|
||||
app={app}
|
||||
webviewRef={webviewRef}
|
||||
currentUrl={currentUrl}
|
||||
onReload={handleReload}
|
||||
onOpenDevTools={handleOpenDevTools}
|
||||
/>
|
||||
|
||||
<WebviewArea>
|
||||
{!isReady && (
|
||||
<LoadingMask>
|
||||
<LoadingOverlay>
|
||||
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
|
||||
</LoadingOverlay>
|
||||
</LoadingMask>
|
||||
)}
|
||||
|
||||
<WebviewContainer
|
||||
key={app.id}
|
||||
appid={app.id}
|
||||
url={app.url}
|
||||
onSetRefCallback={handleWebviewSetRef}
|
||||
onLoadedCallback={handleWebviewLoaded}
|
||||
onNavigateCallback={handleWebviewNavigate}
|
||||
/>
|
||||
</WebviewArea>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const WebviewArea = styled.div`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
min-height: 0; /* Ensure flex child can shrink */
|
||||
`
|
||||
|
||||
const LoadingMask = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-background);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const LoadingOverlay = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
export default MinAppFullPageView
|
||||
218
src/renderer/src/pages/minapps/components/MinimalToolbar.tsx
Normal file
218
src/renderer/src/pages/minapps/components/MinimalToolbar.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
CodeOutlined,
|
||||
ExportOutlined,
|
||||
LinkOutlined,
|
||||
MinusOutlined,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
webviewRef: React.RefObject<WebviewTag | null>
|
||||
currentUrl: string | null
|
||||
onReload: () => void
|
||||
onOpenDevTools: () => void
|
||||
}
|
||||
|
||||
const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOpenDevTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { minappsOpenLinkExternal } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
const [canGoBack, setCanGoBack] = useState(false)
|
||||
const [canGoForward, setCanGoForward] = useState(false)
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id)
|
||||
const isPinned = pinned.some((item) => item.id === app.id)
|
||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||
|
||||
// Update navigation state
|
||||
const updateNavigationState = useCallback(() => {
|
||||
if (webviewRef.current) {
|
||||
setCanGoBack(webviewRef.current.canGoBack())
|
||||
setCanGoForward(webviewRef.current.canGoForward())
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
if (webviewRef.current && webviewRef.current.canGoBack()) {
|
||||
webviewRef.current.goBack()
|
||||
updateNavigationState()
|
||||
}
|
||||
}, [webviewRef, updateNavigationState])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
if (webviewRef.current && webviewRef.current.canGoForward()) {
|
||||
webviewRef.current.goForward()
|
||||
updateNavigationState()
|
||||
}
|
||||
}, [webviewRef, updateNavigationState])
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
navigate('/apps')
|
||||
}, [navigate])
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
|
||||
updatePinnedMinapps(newPinned)
|
||||
}, [app, isPinned, pinned, updatePinnedMinapps])
|
||||
|
||||
const handleToggleOpenExternal = useCallback(() => {
|
||||
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
|
||||
}, [dispatch, minappsOpenLinkExternal])
|
||||
|
||||
const handleOpenLink = useCallback(() => {
|
||||
const urlToOpen = currentUrl || app.url
|
||||
window.api.openWebsite(urlToOpen)
|
||||
}, [currentUrl, app.url])
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<LeftSection>
|
||||
<ButtonGroup>
|
||||
<Tooltip title={t('minapp.popup.goBack')} placement="bottom">
|
||||
<ToolbarButton onClick={handleGoBack} $disabled={!canGoBack}>
|
||||
<ArrowLeftOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('minapp.popup.goForward')} placement="bottom">
|
||||
<ToolbarButton onClick={handleGoForward} $disabled={!canGoForward}>
|
||||
<ArrowRightOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('minapp.popup.refresh')} placement="bottom">
|
||||
<ToolbarButton onClick={onReload}>
|
||||
<ReloadOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</LeftSection>
|
||||
|
||||
<RightSection>
|
||||
<ButtonGroup>
|
||||
{canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} placement="bottom">
|
||||
<ToolbarButton onClick={handleOpenLink}>
|
||||
<ExportOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canPinned && (
|
||||
<Tooltip
|
||||
title={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')}
|
||||
placement="bottom">
|
||||
<ToolbarButton onClick={handleTogglePin} $active={isPinned}>
|
||||
<PushpinOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
minappsOpenLinkExternal
|
||||
? t('minapp.popup.open_link_external_on')
|
||||
: t('minapp.popup.open_link_external_off')
|
||||
}
|
||||
placement="bottom">
|
||||
<ToolbarButton onClick={handleToggleOpenExternal} $active={minappsOpenLinkExternal}>
|
||||
<LinkOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
{isInDevelopment && (
|
||||
<Tooltip title={t('minapp.popup.devtools')} placement="bottom">
|
||||
<ToolbarButton onClick={onOpenDevTools}>
|
||||
<CodeOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={t('minapp.popup.minimize')} placement="bottom">
|
||||
<ToolbarButton onClick={handleMinimize}>
|
||||
<MinusOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</RightSection>
|
||||
</ToolbarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 35px;
|
||||
padding: 0 12px;
|
||||
background-color: var(--color-background);
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const LeftSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const RightSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
`
|
||||
|
||||
const ToolbarButton = styled.button<{
|
||||
$disabled?: boolean
|
||||
$active?: boolean
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: ${({ $active }) => ($active ? 'var(--color-primary-bg)' : 'transparent')};
|
||||
color: ${({ $disabled, $active }) =>
|
||||
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-2)'};
|
||||
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $disabled, $active }) =>
|
||||
$disabled ? 'transparent' : $active ? 'var(--color-primary-bg)' : 'var(--color-background-soft)'};
|
||||
color: ${({ $disabled, $active }) =>
|
||||
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-1)'};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: ${({ $disabled }) => ($disabled ? 'none' : 'scale(0.95)')};
|
||||
}
|
||||
`
|
||||
|
||||
export default MinimalToolbar
|
||||
@ -1,12 +1,29 @@
|
||||
import { loggerService } from '@logger'
|
||||
import store from '@renderer/store'
|
||||
import { removeTab, setActiveTab } from '@renderer/store/tabs'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import NavigationService from './NavigationService'
|
||||
|
||||
const logger = loggerService.withContext('TabsService')
|
||||
|
||||
class TabsService {
|
||||
private minAppsCache: LRUCache<string, MinAppType> | null = null
|
||||
|
||||
/**
|
||||
* Sets the reference to the mini-apps LRU cache used for managing mini-app lifecycle and cleanup.
|
||||
* This method is required to integrate TabsService with the mini-apps cache system, allowing TabsService
|
||||
* to perform cache cleanup when tabs associated with mini-apps are closed. The cache instance is typically
|
||||
* provided by the mini-app popup system and enables TabsService to maintain cache consistency and prevent
|
||||
* stale data.
|
||||
* @param cache The LRUCache instance containing mini-app data, provided by useMinappPopup.
|
||||
*/
|
||||
public setMinAppsCache(cache: LRUCache<string, MinAppType>) {
|
||||
this.minAppsCache = cache
|
||||
logger.debug('Mini-apps cache reference set in TabsService')
|
||||
}
|
||||
/**
|
||||
* 关闭指定的标签页
|
||||
* @param tabId 要关闭的标签页ID
|
||||
@ -49,6 +66,9 @@ class TabsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up mini-app cache if this is a mini-app tab
|
||||
this.cleanupMinAppCache(tabId)
|
||||
|
||||
// 使用 Redux action 移除标签页
|
||||
store.dispatch(removeTab(tabId))
|
||||
|
||||
@ -56,6 +76,32 @@ class TabsService {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up mini-app cache and WebView state when tab is closed
|
||||
* @param tabId The tab ID to clean up
|
||||
*/
|
||||
private cleanupMinAppCache(tabId: string) {
|
||||
// Check if this is a mini-app tab (format: /apps/{appId})
|
||||
const tabs = store.getState().tabs.tabs
|
||||
const tab = tabs.find((t) => t.id === tabId)
|
||||
|
||||
if (tab && tab.path.startsWith('/apps/')) {
|
||||
const appId = tab.path.replace('/apps/', '')
|
||||
|
||||
if (this.minAppsCache && this.minAppsCache.has(appId)) {
|
||||
logger.debug(`Cleaning up mini-app cache for app: ${appId}`)
|
||||
|
||||
// Remove from LRU cache - this will trigger disposeAfter callback
|
||||
this.minAppsCache.delete(appId)
|
||||
|
||||
// Clear WebView state
|
||||
clearWebviewState(appId)
|
||||
|
||||
logger.info(`Mini-app ${appId} removed from cache due to tab closure`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标签页
|
||||
*/
|
||||
|
||||
55
src/renderer/src/utils/webviewStateManager.ts
Normal file
55
src/renderer/src/utils/webviewStateManager.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('WebviewStateManager')
|
||||
|
||||
// Global WebView loaded states - shared between popup and tab modes
|
||||
const globalWebviewStates = new Map<string, boolean>()
|
||||
|
||||
/**
|
||||
* Set WebView loaded state for a specific app
|
||||
* @param appId - The mini-app ID
|
||||
* @param loaded - Whether the WebView is loaded
|
||||
*/
|
||||
export const setWebviewLoaded = (appId: string, loaded: boolean) => {
|
||||
globalWebviewStates.set(appId, loaded)
|
||||
logger.debug(`WebView state set for ${appId}: ${loaded}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebView loaded state for a specific app
|
||||
* @param appId - The mini-app ID
|
||||
* @returns Whether the WebView is loaded
|
||||
*/
|
||||
export const getWebviewLoaded = (appId: string): boolean => {
|
||||
return globalWebviewStates.get(appId) || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear WebView state for a specific app
|
||||
* @param appId - The mini-app ID
|
||||
*/
|
||||
export const clearWebviewState = (appId: string) => {
|
||||
const wasLoaded = globalWebviewStates.delete(appId)
|
||||
if (wasLoaded) {
|
||||
logger.debug(`WebView state cleared for ${appId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all WebView states
|
||||
*/
|
||||
export const clearAllWebviewStates = () => {
|
||||
const count = globalWebviewStates.size
|
||||
globalWebviewStates.clear()
|
||||
logger.debug(`Cleared all WebView states (${count} apps)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded app IDs
|
||||
* @returns Array of app IDs that have loaded WebViews
|
||||
*/
|
||||
export const getLoadedAppIds = (): string[] => {
|
||||
return Array.from(globalWebviewStates.entries())
|
||||
.filter(([, loaded]) => loaded)
|
||||
.map(([appId]) => appId)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user