dev #5

Merged
linkong merged 35 commits from dev into main 2026-04-13 23:41:04 +00:00
11 changed files with 494 additions and 139 deletions
Showing only changes of commit 2ee4773f4f - Show all commits

View File

@@ -1 +1 @@
0.26.1 0.26.2

View File

@@ -23,6 +23,26 @@ Released: 2026-04-12
- Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) by refactoring repeated iframe/video reset paths into shared helpers, so TV source switching, empty-state fallback, and playback retry handling no longer duplicate cleanup logic across multiple branches. - Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) by refactoring repeated iframe/video reset paths into shared helpers, so TV source switching, empty-state fallback, and playback retry handling no longer duplicate cleanup logic across multiple branches.
## 0.26.2
Released: 2026-04-12
### Highlights
- Stabilized the new reusable scrollbar work by restoring reliable sidebar visibility and extending the same floating scrollbar language to the Data Sources tables without letting scrollbars squeeze layout width or regress the console navigation.
### Added
- Added [frontend/src/components/Scrollbar/ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx) as an overlay variant that binds to existing scroll containers such as Ant Table bodies, so heavy data grids can adopt the new scrollbar visuals without replacing their built-in scrolling mechanics.
### Improved
- Improved [frontend/src/components/Scrollbar/Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx), [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by reverting the sidebar to a reliable vertical-first scrollbar path, then reintroducing automatic dual-axis support with independent floating tracks that no longer hide the thumb when only the sidebar needs vertical scrolling.
### Fixed
- Fixed [frontend/src/pages/DataSources/DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) so the built-in and custom datasource tables now use the new overlay scrollbar instead of native table scrollbars, keeping horizontal and vertical scrolling available without changing Ant Tables internal layout behavior.
## 0.26.0 ## 0.26.0
Released: 2026-04-12 Released: 2026-04-12

View File

@@ -16,7 +16,7 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.26.1` - `dev` 当前开发分支历史推导到:`0.26.2`
## Timeline ## Timeline
@@ -87,6 +87,7 @@
| `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set | | `0.25.3` | bugfix | `dev` | `pending` | refactor the Earth HUD visual system, extract the top-left Earth brand into a reusable language-driven component, and consolidate duplicated brand assets into a single canonical set |
| `0.26.0` | feature | `dev` | `pending` | add the Earth TV live module with backend-configurable sources, a draggable/resizable TV HUD window, a first curated news channel catalog, and TV source management hooks in system settings | | `0.26.0` | feature | `dev` | `pending` | add the Earth TV live module with backend-configurable sources, a draggable/resizable TV HUD window, a first curated news channel catalog, and TV source management hooks in system settings |
| `0.26.1` | bugfix | `dev` | `pending` | extract the dashboard sidebar scrollbar into a reusable component and clean duplicated Earth TV player reset logic after the first live-module rollout | | `0.26.1` | bugfix | `dev` | `pending` | extract the dashboard sidebar scrollbar into a reusable component and clean duplicated Earth TV player reset logic after the first live-module rollout |
| `0.26.2` | bugfix | `dev` | `pending` | stabilize the reusable sidebar scrollbar, restore automatic dual-axis floating tracks safely, and apply the same overlay scrollbar system to datasource tables |
## Maintenance Commits Not Counted as Version Bumps ## Maintenance Commits Not Counted as Version Bumps

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.26.1", "version": "0.26.2",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {

View File

@@ -133,7 +133,7 @@ function AppLayout({ children }: AppLayoutProps) {
<Text strong style={{ color: 'white', fontSize: 18 }}></Text> <Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null} ) : null}
</div> </div>
<Scrollbar className="dashboard-sider-menu-scroll" axis="y"> <Scrollbar className="dashboard-sider-menu-scroll">
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"

View File

@@ -8,9 +8,8 @@ import {
} from 'react' } from 'react'
type Axis = 'x' | 'y' type Axis = 'x' | 'y'
type ScrollbarAxis = 'x' | 'y' | 'both'
interface AxisScrollbarState { interface AxisState {
visible: boolean visible: boolean
dragging: boolean dragging: boolean
thumbSize: number thumbSize: number
@@ -18,17 +17,14 @@ interface AxisScrollbarState {
} }
interface ScrollbarState { interface ScrollbarState {
x: AxisScrollbarState x: AxisState
y: AxisScrollbarState y: AxisState
} }
interface ScrollbarProps { interface ScrollbarProps {
children: ReactNode children: ReactNode
className?: string className?: string
minThumbSize?: number minThumbSize?: number
axis?: ScrollbarAxis
suppressScrollX?: boolean
suppressScrollY?: boolean
} }
interface DragState { interface DragState {
@@ -37,7 +33,7 @@ interface DragState {
pointerOffset: number pointerOffset: number
} }
const INITIAL_AXIS_STATE: AxisScrollbarState = { const INITIAL_AXIS_STATE: AxisState = {
visible: false, visible: false,
dragging: false, dragging: false,
thumbSize: 0, thumbSize: 0,
@@ -53,9 +49,6 @@ function Scrollbar({
children, children,
className = '', className = '',
minThumbSize = 28, minThumbSize = 28,
axis = 'both',
suppressScrollX = false,
suppressScrollY = false,
}: ScrollbarProps) { }: ScrollbarProps) {
const viewportRef = useRef<HTMLDivElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null)
const trackXRef = useRef<HTMLDivElement | null>(null) const trackXRef = useRef<HTMLDivElement | null>(null)
@@ -78,35 +71,31 @@ function Scrollbar({
scrollTop, scrollTop,
} = viewport } = viewport
const enableX = axis === 'x' || axis === 'both' const hasOverflowX = scrollWidth - clientWidth > 1
const enableY = axis === 'y' || axis === 'both' const hasOverflowY = scrollHeight - clientHeight > 1
const hasOverflowX = enableX && !suppressScrollX && scrollWidth - clientWidth > 1
const hasOverflowY = enableY && !suppressScrollY && scrollHeight - clientHeight > 1
const nextX: AxisScrollbarState = hasOverflowX && clientWidth > 0 const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0
? (() => { ? (() => {
const trackSize = trackX.clientWidth const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth)
const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackSize) const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize)
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
const scrollRange = Math.max(1, scrollWidth - clientWidth) const scrollRange = Math.max(1, scrollWidth - clientWidth)
return { return {
visible: true, visible: true,
dragging: scrollbar.x.dragging && dragStateRef.current?.axis === 'x', dragging: dragStateRef.current?.axis === 'x',
thumbSize, thumbSize,
thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset, thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset,
} }
})() })()
: { ...INITIAL_AXIS_STATE } : { ...INITIAL_AXIS_STATE }
const nextY: AxisScrollbarState = hasOverflowY && clientHeight > 0 const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0
? (() => { ? (() => {
const trackSize = trackY.clientHeight const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight)
const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackSize) const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize)
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
const scrollRange = Math.max(1, scrollHeight - clientHeight) const scrollRange = Math.max(1, scrollHeight - clientHeight)
return { return {
visible: true, visible: true,
dragging: scrollbar.y.dragging && dragStateRef.current?.axis === 'y', dragging: dragStateRef.current?.axis === 'y',
thumbSize, thumbSize,
thumbOffset: (scrollTop / scrollRange) * maxThumbOffset, thumbOffset: (scrollTop / scrollRange) * maxThumbOffset,
} }
@@ -114,7 +103,7 @@ function Scrollbar({
: { ...INITIAL_AXIS_STATE } : { ...INITIAL_AXIS_STATE }
setScrollbar({ x: nextX, y: nextY }) setScrollbar({ x: nextX, y: nextY })
}, [minThumbSize, scrollbar.x.dragging, scrollbar.y.dragging, suppressScrollX, suppressScrollY]) }, [minThumbSize])
useEffect(() => { useEffect(() => {
const viewport = viewportRef.current const viewport = viewportRef.current
@@ -122,23 +111,45 @@ function Scrollbar({
const trackY = trackYRef.current const trackY = trackYRef.current
if (!viewport || !trackX || !trackY) return if (!viewport || !trackX || !trackY) return
let rafId = 0
const scheduleUpdate = () => {
cancelAnimationFrame(rafId)
rafId = window.requestAnimationFrame(() => {
updateScrollbar()
})
}
updateScrollbar() updateScrollbar()
scheduleUpdate()
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
updateScrollbar() scheduleUpdate()
})
const mutationObserver = new MutationObserver(() => {
scheduleUpdate()
}) })
resizeObserver.observe(viewport) resizeObserver.observe(viewport)
resizeObserver.observe(trackX) resizeObserver.observe(trackX)
resizeObserver.observe(trackY) resizeObserver.observe(trackY)
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child)) Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
viewport.addEventListener('scroll', updateScrollbar, { passive: true })
window.addEventListener('resize', updateScrollbar) mutationObserver.observe(viewport, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
})
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })
window.addEventListener('resize', scheduleUpdate)
return () => { return () => {
cancelAnimationFrame(rafId)
resizeObserver.disconnect() resizeObserver.disconnect()
viewport.removeEventListener('scroll', updateScrollbar) mutationObserver.disconnect()
window.removeEventListener('resize', updateScrollbar) viewport.removeEventListener('scroll', scheduleUpdate)
window.removeEventListener('resize', scheduleUpdate)
} }
}, [children, updateScrollbar]) }, [children, updateScrollbar])
@@ -177,19 +188,16 @@ function Scrollbar({
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
if (axis === 'x') { if (axis === 'x') {
const scrollRange = Math.max(0, viewport.scrollWidth - viewport.clientWidth) viewport.scrollLeft = progress * Math.max(0, viewport.scrollWidth - viewport.clientWidth)
viewport.scrollLeft = progress * scrollRange
return return
} }
const scrollRange = Math.max(0, viewport.scrollHeight - viewport.clientHeight) viewport.scrollTop = progress * Math.max(0, viewport.scrollHeight - viewport.clientHeight)
viewport.scrollTop = progress * scrollRange
} }
const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => { const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect() if (!scrollbar[axis].visible) return
const axisState = scrollbar[axis]
if (!axisState.visible) return
const rect = event.currentTarget.getBoundingClientRect()
dragStateRef.current = { dragStateRef.current = {
axis, axis,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -207,9 +215,9 @@ function Scrollbar({
} }
const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => { const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const track = axis === 'x' ? trackXRef.current : trackYRef.current
const dragState = dragStateRef.current const dragState = dragStateRef.current
if (!track || !dragState || dragState.pointerId !== event.pointerId || dragState.axis !== axis) return const track = axis === 'x' ? trackXRef.current : trackYRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return
const rect = track.getBoundingClientRect() const rect = track.getBoundingClientRect()
const nextThumbOffset = axis === 'x' const nextThumbOffset = axis === 'x'
@@ -219,7 +227,9 @@ function Scrollbar({
} }
const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => { const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current.axis !== axis) return const dragState = dragStateRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return
dragStateRef.current = null dragStateRef.current = null
document.body.style.userSelect = '' document.body.style.userSelect = ''
setScrollbar((previous) => ({ setScrollbar((previous) => ({
@@ -230,70 +240,60 @@ function Scrollbar({
} }
const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => { const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const axisState = scrollbar[axis] if (!scrollbar[axis].visible || event.target !== event.currentTarget) return
if (!axisState.visible || event.target !== event.currentTarget) return
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
const nextThumbOffset = axis === 'x' const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - axisState.thumbSize / 2 ? event.clientX - rect.left - scrollbar.x.thumbSize / 2
: event.clientY - rect.top - axisState.thumbSize / 2 : event.clientY - rect.top - scrollbar.y.thumbSize / 2
syncScrollFromThumbOffset(axis, nextThumbOffset) syncScrollFromThumbOffset(axis, nextThumbOffset)
} }
const showCorner = scrollbar.x.visible && scrollbar.y.visible const hasCorner = scrollbar.x.visible && scrollbar.y.visible
const scrollbarClassName = [
'scrollbar',
`scrollbar--${axis}`,
className,
].filter(Boolean).join(' ')
return ( return (
<div className={scrollbarClassName}> <div className={['scrollbar', className].filter(Boolean).join(' ')}>
<div ref={viewportRef} className="scrollbar__viewport"> <div ref={viewportRef} className="scrollbar__viewport">
{children} {children}
</div> </div>
{(axis === 'y' || axis === 'both') ? ( <div
<div ref={trackYRef}
ref={trackYRef} className={`scrollbar__track scrollbar__track--y${scrollbar.y.visible ? ' scrollbar__track--visible' : ''}${scrollbar.y.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
className={`scrollbar__track scrollbar__track--y${scrollbar.y.visible ? ' scrollbar__track--visible' : ''}${scrollbar.y.dragging ? ' scrollbar__track--dragging' : ''}${showCorner ? ' scrollbar__track--has-corner' : ''}`} onPointerDown={handleTrackPointerDown('y')}
onPointerDown={handleTrackPointerDown('y')} >
> {scrollbar.y.visible ? (
{scrollbar.y.visible ? ( <div
<div className="scrollbar__thumb scrollbar__thumb--y"
className="scrollbar__thumb scrollbar__thumb--y" style={{
style={{ height: `${scrollbar.y.thumbSize}px`,
height: `${scrollbar.y.thumbSize}px`, transform: `translateY(${scrollbar.y.thumbOffset}px)`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`, }}
}} onPointerDown={handleThumbPointerDown('y')}
onPointerDown={handleThumbPointerDown('y')} onPointerMove={handleThumbPointerMove('y')}
onPointerMove={handleThumbPointerMove('y')} onPointerUp={handleThumbPointerUp('y')}
onPointerUp={handleThumbPointerUp('y')} />
/> ) : null}
) : null} </div>
</div>
) : null}
{(axis === 'x' || axis === 'both') ? ( <div
<div ref={trackXRef}
ref={trackXRef} className={`scrollbar__track scrollbar__track--x${scrollbar.x.visible ? ' scrollbar__track--visible' : ''}${scrollbar.x.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
className={`scrollbar__track scrollbar__track--x${scrollbar.x.visible ? ' scrollbar__track--visible' : ''}${scrollbar.x.dragging ? ' scrollbar__track--dragging' : ''}${showCorner ? ' scrollbar__track--has-corner' : ''}`} onPointerDown={handleTrackPointerDown('x')}
onPointerDown={handleTrackPointerDown('x')} >
> {scrollbar.x.visible ? (
{scrollbar.x.visible ? ( <div
<div className="scrollbar__thumb scrollbar__thumb--x"
className="scrollbar__thumb scrollbar__thumb--x" style={{
style={{ width: `${scrollbar.x.thumbSize}px`,
width: `${scrollbar.x.thumbSize}px`, transform: `translateX(${scrollbar.x.thumbOffset}px)`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`, }}
}} onPointerDown={handleThumbPointerDown('x')}
onPointerDown={handleThumbPointerDown('x')} onPointerMove={handleThumbPointerMove('x')}
onPointerMove={handleThumbPointerMove('x')} onPointerUp={handleThumbPointerUp('x')}
onPointerUp={handleThumbPointerUp('x')} />
/> ) : null}
) : null} </div>
</div>
) : null}
</div> </div>
) )
} }

View File

@@ -0,0 +1,327 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Axis = 'x' | 'y'
interface AxisState {
visible: boolean
dragging: boolean
thumbSize: number
thumbOffset: number
}
interface ScrollbarState {
x: AxisState
y: AxisState
}
interface ScrollbarOverlayProps {
containerRef: RefObject<HTMLElement | null>
targetSelector: string
minThumbSize?: number
}
interface DragState {
axis: Axis
pointerId: number
pointerOffset: number
}
const INITIAL_AXIS_STATE: AxisState = {
visible: false,
dragging: false,
thumbSize: 0,
thumbOffset: 0,
}
const INITIAL_STATE: ScrollbarState = {
x: INITIAL_AXIS_STATE,
y: INITIAL_AXIS_STATE,
}
function ScrollbarOverlay({
containerRef,
targetSelector,
minThumbSize = 28,
}: ScrollbarOverlayProps) {
const trackXRef = useRef<HTMLDivElement | null>(null)
const trackYRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<DragState | null>(null)
const targetRef = useRef<HTMLElement | null>(null)
const [scrollbar, setScrollbar] = useState<ScrollbarState>(INITIAL_STATE)
const bindTarget = useCallback(() => {
const container = containerRef.current
if (!container) {
targetRef.current = null
return null
}
const target = container.querySelector<HTMLElement>(targetSelector)
targetRef.current = target
return target
}, [containerRef, targetSelector])
const updateScrollbar = useCallback(() => {
const target = targetRef.current ?? bindTarget()
const trackX = trackXRef.current
const trackY = trackYRef.current
if (!target || !trackX || !trackY) return
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollLeft,
scrollTop,
} = target
const hasOverflowX = scrollWidth - clientWidth > 1
const hasOverflowY = scrollHeight - clientHeight > 1
const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0
? (() => {
const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth)
const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize)
const scrollRange = Math.max(1, scrollWidth - clientWidth)
return {
visible: true,
dragging: dragStateRef.current?.axis === 'x',
thumbSize,
thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0
? (() => {
const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight)
const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize)
const scrollRange = Math.max(1, scrollHeight - clientHeight)
return {
visible: true,
dragging: dragStateRef.current?.axis === 'y',
thumbSize,
thumbOffset: (scrollTop / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
setScrollbar({ x: nextX, y: nextY })
}, [bindTarget, minThumbSize])
useEffect(() => {
const container = containerRef.current
if (!container) return
let cleanupTargetScroll: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId = 0
const scheduleUpdate = () => {
cancelAnimationFrame(rafId)
rafId = window.requestAnimationFrame(() => {
updateScrollbar()
})
}
const attachTarget = () => {
cleanupTargetScroll?.()
resizeObserver?.disconnect()
const target = bindTarget()
if (!target) {
targetRef.current = null
return
}
const handleScroll = () => {
scheduleUpdate()
}
target.addEventListener('scroll', handleScroll, { passive: true })
cleanupTargetScroll = () => {
target.removeEventListener('scroll', handleScroll)
}
resizeObserver = new ResizeObserver(() => {
scheduleUpdate()
})
resizeObserver.observe(target)
Array.from(target.children).forEach((child) => resizeObserver?.observe(child))
scheduleUpdate()
}
attachTarget()
mutationObserver = new MutationObserver(() => {
attachTarget()
scheduleUpdate()
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
})
window.addEventListener('resize', scheduleUpdate)
return () => {
cancelAnimationFrame(rafId)
cleanupTargetScroll?.()
resizeObserver?.disconnect()
mutationObserver?.disconnect()
window.removeEventListener('resize', scheduleUpdate)
}
}, [bindTarget, containerRef, updateScrollbar])
useEffect(() => {
const stopDragging = () => {
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
}
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
return () => {
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
}, [])
const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => {
const target = targetRef.current
const track = axis === 'x' ? trackXRef.current : trackYRef.current
const axisState = scrollbar[axis]
if (!target || !track) return
const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight
const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize)
if (maxThumbOffset <= 0) {
if (axis === 'x') target.scrollLeft = 0
else target.scrollTop = 0
return
}
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
if (axis === 'x') {
target.scrollLeft = progress * Math.max(0, target.scrollWidth - target.clientWidth)
return
}
target.scrollTop = progress * Math.max(0, target.scrollHeight - target.clientHeight)
}
const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (!scrollbar[axis].visible) return
const rect = event.currentTarget.getBoundingClientRect()
dragStateRef.current = {
axis,
pointerId: event.pointerId,
pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top,
}
setScrollbar((previous) => ({
x: { ...previous.x, dragging: axis === 'x' },
y: { ...previous.y, dragging: axis === 'y' },
}))
document.body.style.userSelect = 'none'
event.currentTarget.setPointerCapture(event.pointerId)
event.stopPropagation()
event.preventDefault()
}
const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
const track = axis === 'x' ? trackXRef.current : trackYRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return
const rect = track.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - dragState.pointerOffset
: event.clientY - rect.top - dragState.pointerOffset
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
event.currentTarget.releasePointerCapture?.(event.pointerId)
}
const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (!scrollbar[axis].visible || event.target !== event.currentTarget) return
const rect = event.currentTarget.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - scrollbar.x.thumbSize / 2
: event.clientY - rect.top - scrollbar.y.thumbSize / 2
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const hasCorner = scrollbar.x.visible && scrollbar.y.visible
return (
<div className="scrollbar scrollbar--overlay">
<div
ref={trackYRef}
className={`scrollbar__track scrollbar__track--y${scrollbar.y.visible ? ' scrollbar__track--visible' : ''}${scrollbar.y.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('y')}
>
{scrollbar.y.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--y"
style={{
height: `${scrollbar.y.thumbSize}px`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('y')}
onPointerMove={handleThumbPointerMove('y')}
onPointerUp={handleThumbPointerUp('y')}
/>
) : null}
</div>
<div
ref={trackXRef}
className={`scrollbar__track scrollbar__track--x${scrollbar.x.visible ? ' scrollbar__track--visible' : ''}${scrollbar.x.dragging ? ' scrollbar__track--dragging' : ''}${hasCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('x')}
>
{scrollbar.x.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--x"
style={{
width: `${scrollbar.x.thumbSize}px`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('x')}
onPointerMove={handleThumbPointerMove('x')}
onPointerUp={handleThumbPointerUp('x')}
/>
) : null}
</div>
</div>
)
}
export default ScrollbarOverlay

View File

@@ -91,11 +91,6 @@ body {
overflow: hidden; overflow: hidden;
} }
.scrollbar--y .scrollbar__track--x,
.scrollbar--x .scrollbar__track--y {
display: none;
}
.scrollbar__viewport { .scrollbar__viewport {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
@@ -117,29 +112,35 @@ body {
.scrollbar__track--y { .scrollbar__track--y {
top: 0; top: 0;
right: 8px; right: 0;
width: 0; width: 12px;
height: 100%; height: 100%;
} }
.scrollbar__track--x { .scrollbar__track--x {
left: 0; left: 0;
bottom: 8px; bottom: 0;
width: 100%; width: 100%;
height: 0; height: 12px;
}
.scrollbar__track--y.scrollbar__track--has-corner {
height: calc(100% - 12px);
}
.scrollbar__track--x.scrollbar__track--has-corner {
width: calc(100% - 12px);
} }
.scrollbar__thumb { .scrollbar__thumb {
position: absolute; position: absolute;
min-height: 28px;
min-width: 28px;
border-radius: 999px; border-radius: 999px;
background: rgba(196, 208, 224, 0.46); background: rgba(196, 208, 224, 0.46);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
pointer-events: auto; pointer-events: auto;
transition: opacity 0.18s ease, background 0.18s ease, width 0.18s ease; transition: opacity 0.18s ease, background 0.18s ease;
} }
.scrollbar__thumb::after { .scrollbar__thumb::after {
@@ -149,24 +150,26 @@ body {
.scrollbar__thumb--y { .scrollbar__thumb--y {
top: 8px; top: 8px;
right: 0; right: 3px;
width: 6px; width: 4px;
min-width: 6px; min-width: 4px;
min-height: 28px;
} }
.scrollbar__thumb--y::after { .scrollbar__thumb--y::after {
inset: 0 -6px; inset: 0 -4px;
} }
.scrollbar__thumb--x { .scrollbar__thumb--x {
left: 8px; left: 8px;
bottom: 0; bottom: 3px;
height: 6px; height: 4px;
min-height: 6px; min-height: 4px;
min-width: 28px;
} }
.scrollbar__thumb--x::after { .scrollbar__thumb--x::after {
inset: -6px 0; inset: -4px 0;
} }
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb, .scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
@@ -175,6 +178,10 @@ body {
opacity: 1; opacity: 1;
} }
.scrollbar__track--visible {
pointer-events: auto;
}
.scrollbar__thumb:hover { .scrollbar__thumb:hover {
background: rgba(216, 226, 240, 0.68); background: rgba(216, 226, 240, 0.68);
} }
@@ -1555,6 +1562,7 @@ body {
} }
.table-scroll-region { .table-scroll-region {
position: relative;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
@@ -1575,35 +1583,31 @@ body {
padding: 10px 12px !important; padding: 10px 12px !important;
} }
.table-scroll-region .ant-table-body, .data-source-table-region .ant-table-body,
.table-scroll-region .ant-table-content { .data-source-table-region .ant-table-content {
scrollbar-width: thin; scrollbar-width: none;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent; -ms-overflow-style: none;
} }
.table-scroll-region .ant-table-body::-webkit-scrollbar, .data-source-table-region .ant-table-body::-webkit-scrollbar,
.table-scroll-region .ant-table-content::-webkit-scrollbar { .data-source-table-region .ant-table-content::-webkit-scrollbar {
width: 8px; width: 0;
height: 8px; height: 0;
} }
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb, .scrollbar--overlay {
.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb { position: absolute;
background: rgba(148, 163, 184, 0.82); inset: 0;
border-radius: 999px; z-index: 3;
border: 2px solid transparent; pointer-events: none;
background-clip: padding-box;
} }
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb:hover, .scrollbar--overlay .scrollbar__track--y {
.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb:hover { right: 2px;
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
} }
.table-scroll-region .ant-table-body::-webkit-scrollbar-track, .scrollbar--overlay .scrollbar__track--x {
.table-scroll-region .ant-table-content::-webkit-scrollbar-track { bottom: 2px;
background: transparent;
} }
.bgp-page { .bgp-page {

View File

@@ -11,6 +11,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import axios, { type AxiosResponse } from 'axios' import axios, { type AxiosResponse } from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
import { useWebSocket } from '../../hooks/useWebSocket' import { useWebSocket } from '../../hooks/useWebSocket'
@@ -1050,6 +1051,7 @@ function DataSources() {
tableLayout="fixed" tableLayout="fixed"
size="small" size="small"
/> />
<ScrollbarOverlay containerRef={builtinTableRegionRef} targetSelector=".ant-table-body" />
</div> </div>
</div> </div>
), ),
@@ -1084,6 +1086,7 @@ function DataSources() {
tableLayout="fixed" tableLayout="fixed"
size="small" size="small"
/> />
<ScrollbarOverlay containerRef={customTableRegionRef} targetSelector=".ant-table-body" />
</div> </div>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.26.1" version = "0.26.2"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.26.1" version = "0.26.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },