release: bump version to 0.26.1
This commit is contained in:
@@ -7,27 +7,21 @@ This project follows the repository versioning rule:
|
|||||||
- `feature` -> `+0.1.0`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
## 0.25.3
|
## 0.26.1
|
||||||
|
|
||||||
Released: 2026-04-11
|
Released: 2026-04-12
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|
||||||
- Refined the Earth HUD visual system into a calmer operator-facing style, turned the top-left Earth brand into a real reusable component with language-driven rendering, and cleaned up duplicated brand assets so the page now has a single source of truth for HUD branding.
|
- Cleaned up the first TV follow-up and replaced the dashboard sidebar's brittle one-off scroll behavior with a reusable `Scrollbar` component, so the new live module code is easier to maintain and the console navigation can scroll without layout jitter.
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added [frontend/public/earth/js/brand.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/brand.js) as a reusable Earth `brand` component that renders the top-left logo/title/subtitle block from a shared config instead of hardcoding the structure in HTML.
|
|
||||||
- Added [frontend/public/earth/assets/brand/earth-logo.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/earth-logo.svg), [frontend/public/earth/assets/brand/title-zh.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-zh.svg), and [frontend/public/earth/assets/brand/title-en.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-en.svg) as the canonical Earth brand asset set.
|
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css), [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), [frontend/public/earth/css/coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css), [frontend/public/earth/css/legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css), [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css), and [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) by rebalancing the Earth HUD into a more restrained deep-blue control-room look instead of the earlier over-layered glass-and-neon mix.
|
- 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 extracting the sidebar scrollbar into a dedicated component with explicit `x / y / both` axis support, hidden native scrollbars, compact account/version rows, and a sidebar-only vertical setup instead of the earlier patchwork CSS glued directly onto the layout.
|
||||||
- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), and [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by moving the Earth brand mount to a dedicated root and letting `HUD_CONFIG.brandLanguage` choose between `zh` and `en` without embedding the language decision in the DOM.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles.
|
- 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.0
|
## 0.26.0
|
||||||
|
|
||||||
@@ -53,6 +47,28 @@ Released: 2026-04-12
|
|||||||
- Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) and [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) so HLS/video playback now actively attempts autoplay in the TV panel instead of only loading metadata and leaving the player visually idle.
|
- Fixed [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) and [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) so HLS/video playback now actively attempts autoplay in the TV panel instead of only loading metadata and leaving the player visually idle.
|
||||||
- Fixed [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css) so the TV source selector and action controls better match the Earth HUD dark theme instead of falling back to a bright native dropdown presentation.
|
- Fixed [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css) so the TV source selector and action controls better match the Earth HUD dark theme instead of falling back to a bright native dropdown presentation.
|
||||||
|
|
||||||
|
## 0.25.3
|
||||||
|
|
||||||
|
Released: 2026-04-11
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Refined the Earth HUD visual system into a calmer operator-facing style, turned the top-left Earth brand into a real reusable component with language-driven rendering, and cleaned up duplicated brand assets so the page now has a single source of truth for HUD branding.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added [frontend/public/earth/js/brand.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/brand.js) as a reusable Earth `brand` component that renders the top-left logo/title/subtitle block from a shared config instead of hardcoding the structure in HTML.
|
||||||
|
- Added [frontend/public/earth/assets/brand/earth-logo.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/earth-logo.svg), [frontend/public/earth/assets/brand/title-zh.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-zh.svg), and [frontend/public/earth/assets/brand/title-en.svg](/home/ray/dev/linkong/planet/frontend/public/earth/assets/brand/title-en.svg) as the canonical Earth brand asset set.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Improved [frontend/public/earth/css/base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css), [frontend/public/earth/css/hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css), [frontend/public/earth/css/coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css), [frontend/public/earth/css/legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css), [frontend/public/earth/css/earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css), and [frontend/public/earth/css/toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) by rebalancing the Earth HUD into a more restrained deep-blue control-room look instead of the earlier over-layered glass-and-neon mix.
|
||||||
|
- Improved [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js), and [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by moving the Earth brand mount to a dedicated root and letting `HUD_CONFIG.brandLanguage` choose between `zh` and `en` without embedding the language decision in the DOM.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed [frontend/public/earth/css/info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) so the Earth brand area now uses consistent `earth-brand` component selectors and English-specific typography hooks, avoiding the earlier one-off `brand-banner` naming drift and duplicated title styles.
|
||||||
|
|
||||||
## 0.25.2
|
## 0.25.2
|
||||||
|
|
||||||
Released: 2026-04-10
|
Released: 2026-04-10
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.26.0`
|
- `dev` 当前开发分支历史推导到:`0.26.1`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
| `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions |
|
| `0.25.2` | bugfix | `dev` | `pending` | refine Earth settings modal sizing, eliminate first-frame HUD scale flicker, and make dragged HUD panels animate cleanly through maximized layout transitions |
|
||||||
| `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 |
|
||||||
|
|
||||||
## Maintenance Commits Not Counted as Version Bumps
|
## Maintenance Commits Not Counted as Version Bumps
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.26.0",
|
"version": "0.26.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -371,6 +371,10 @@ function findSourceById(sourceId) {
|
|||||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentSource() {
|
||||||
|
return findSourceById(currentSourceId);
|
||||||
|
}
|
||||||
|
|
||||||
function setPanelMessage(message) {
|
function setPanelMessage(message) {
|
||||||
const { status } = getElements();
|
const { status } = getElements();
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -378,6 +382,50 @@ function setPanelMessage(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetIframe(iframe) {
|
||||||
|
if (!(iframe instanceof HTMLIFrameElement)) return;
|
||||||
|
iframe.removeAttribute("src");
|
||||||
|
iframe.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVideo(video) {
|
||||||
|
if (!(video instanceof HTMLVideoElement)) return;
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.hidden = true;
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState(empty, message) {
|
||||||
|
if (!(empty instanceof HTMLElement)) return;
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEmptyState(empty) {
|
||||||
|
if (empty instanceof HTMLElement) {
|
||||||
|
empty.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideoSource(video, iframe, source) {
|
||||||
|
resetIframe(iframe);
|
||||||
|
if (video instanceof HTMLVideoElement) {
|
||||||
|
video.hidden = false;
|
||||||
|
attachVideoSource(video, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmbeddedSource(iframe, video, embeddedUrl) {
|
||||||
|
destroyHlsPlayer();
|
||||||
|
resetVideo(video);
|
||||||
|
if (iframe instanceof HTMLIFrameElement) {
|
||||||
|
iframe.hidden = false;
|
||||||
|
if (iframe.src !== embeddedUrl) {
|
||||||
|
iframe.src = embeddedUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSourceOptions() {
|
function renderSourceOptions() {
|
||||||
const { select } = getElements();
|
const { select } = getElements();
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@@ -429,53 +477,26 @@ function renderSource(source) {
|
|||||||
|
|
||||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||||
destroyHlsPlayer();
|
destroyHlsPlayer();
|
||||||
if (iframe) {
|
resetIframe(iframe);
|
||||||
iframe.removeAttribute("src");
|
resetVideo(video);
|
||||||
iframe.hidden = true;
|
showEmptyState(
|
||||||
}
|
empty,
|
||||||
if (video) {
|
isExternalOnly
|
||||||
video.removeAttribute("src");
|
|
||||||
video.hidden = true;
|
|
||||||
video.load();
|
|
||||||
}
|
|
||||||
if (empty) {
|
|
||||||
empty.hidden = false;
|
|
||||||
empty.textContent = isExternalOnly
|
|
||||||
? "当前频道仅支持跳转官网或外部播放器打开。"
|
? "当前频道仅支持跳转官网或外部播放器打开。"
|
||||||
: "暂无可播放直播源,请先在系统配置中添加频道。";
|
: "暂无可播放直播源,请先在系统配置中添加频道。",
|
||||||
}
|
);
|
||||||
setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty);
|
setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty);
|
||||||
updateOpenButton(source);
|
updateOpenButton(source);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo && videoUrl) {
|
if (isVideo && videoUrl) {
|
||||||
if (iframe) {
|
renderVideoSource(video, iframe, source);
|
||||||
iframe.removeAttribute("src");
|
|
||||||
iframe.hidden = true;
|
|
||||||
}
|
|
||||||
if (video) {
|
|
||||||
video.hidden = false;
|
|
||||||
attachVideoSource(video, source);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
destroyHlsPlayer();
|
renderEmbeddedSource(iframe, video, embeddedUrl);
|
||||||
if (video) {
|
|
||||||
video.removeAttribute("src");
|
|
||||||
video.hidden = true;
|
|
||||||
video.load();
|
|
||||||
}
|
|
||||||
if (iframe) {
|
|
||||||
iframe.hidden = false;
|
|
||||||
if (iframe.src !== embeddedUrl) {
|
|
||||||
iframe.src = embeddedUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty) {
|
hideEmptyState(empty);
|
||||||
empty.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPanelMessage(
|
setPanelMessage(
|
||||||
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
|
||||||
@@ -586,7 +607,7 @@ export function initTVPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
video?.addEventListener("error", () => {
|
video?.addEventListener("error", () => {
|
||||||
const currentSource = findSourceById(currentSourceId);
|
const currentSource = getCurrentSource();
|
||||||
if (!showEmbeddedFallback(currentSource)) {
|
if (!showEmbeddedFallback(currentSource)) {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import packageJson from '../../../package.json'
|
import packageJson from '../../../package.json'
|
||||||
|
import Scrollbar from '../Scrollbar/Scrollbar'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@@ -117,7 +118,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
className="dashboard-sider"
|
className="dashboard-sider"
|
||||||
>
|
>
|
||||||
<div className="dashboard-sider-inner">
|
<div className="dashboard-sider-inner">
|
||||||
<div>
|
<div className="dashboard-sider-main">
|
||||||
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
|
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -132,31 +133,33 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Scrollbar className="dashboard-sider-menu-scroll" axis="y">
|
||||||
theme="dark"
|
<Menu
|
||||||
mode="inline"
|
theme="dark"
|
||||||
selectedKeys={[selectedKey]}
|
mode="inline"
|
||||||
openKeys={collapsed ? [] : openKeys}
|
selectedKeys={[selectedKey]}
|
||||||
items={menuItems}
|
openKeys={collapsed ? [] : openKeys}
|
||||||
onOpenChange={(keys) => {
|
items={menuItems}
|
||||||
updateOpenKeys(keys as string[])
|
onOpenChange={(keys) => {
|
||||||
}}
|
updateOpenKeys(keys as string[])
|
||||||
onClick={({ key }) => {
|
}}
|
||||||
if (key !== selectedKey) {
|
onClick={({ key }) => {
|
||||||
navigate(key)
|
if (key !== selectedKey) {
|
||||||
}
|
navigate(key)
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBanner && !collapsed ? (
|
{showBanner && !collapsed ? (
|
||||||
<div className="dashboard-sider-banner">
|
<div className="dashboard-sider-banner">
|
||||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
<div>
|
<div className="dashboard-sider-banner-row">
|
||||||
<Text className="dashboard-sider-banner-label">当前账号</Text>
|
<Text className="dashboard-sider-banner-label">当前账号</Text>
|
||||||
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
|
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="dashboard-sider-banner-row">
|
||||||
<Text className="dashboard-sider-banner-label">版本号</Text>
|
<Text className="dashboard-sider-banner-label">版本号</Text>
|
||||||
<Text strong className="dashboard-sider-banner-value">{appVersion}</Text>
|
<Text strong className="dashboard-sider-banner-value">{appVersion}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
301
frontend/src/components/Scrollbar/Scrollbar.tsx
Normal file
301
frontend/src/components/Scrollbar/Scrollbar.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
type Axis = 'x' | 'y'
|
||||||
|
type ScrollbarAxis = 'x' | 'y' | 'both'
|
||||||
|
|
||||||
|
interface AxisScrollbarState {
|
||||||
|
visible: boolean
|
||||||
|
dragging: boolean
|
||||||
|
thumbSize: number
|
||||||
|
thumbOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrollbarState {
|
||||||
|
x: AxisScrollbarState
|
||||||
|
y: AxisScrollbarState
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrollbarProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
minThumbSize?: number
|
||||||
|
axis?: ScrollbarAxis
|
||||||
|
suppressScrollX?: boolean
|
||||||
|
suppressScrollY?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
axis: Axis
|
||||||
|
pointerId: number
|
||||||
|
pointerOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_AXIS_STATE: AxisScrollbarState = {
|
||||||
|
visible: false,
|
||||||
|
dragging: false,
|
||||||
|
thumbSize: 0,
|
||||||
|
thumbOffset: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: ScrollbarState = {
|
||||||
|
x: INITIAL_AXIS_STATE,
|
||||||
|
y: INITIAL_AXIS_STATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scrollbar({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
minThumbSize = 28,
|
||||||
|
axis = 'both',
|
||||||
|
suppressScrollX = false,
|
||||||
|
suppressScrollY = false,
|
||||||
|
}: ScrollbarProps) {
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const trackXRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const trackYRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const dragStateRef = useRef<DragState | null>(null)
|
||||||
|
const [scrollbar, setScrollbar] = useState<ScrollbarState>(INITIAL_STATE)
|
||||||
|
|
||||||
|
const updateScrollbar = useCallback(() => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
const trackX = trackXRef.current
|
||||||
|
const trackY = trackYRef.current
|
||||||
|
if (!viewport || !trackX || !trackY) return
|
||||||
|
|
||||||
|
const {
|
||||||
|
clientWidth,
|
||||||
|
clientHeight,
|
||||||
|
scrollWidth,
|
||||||
|
scrollHeight,
|
||||||
|
scrollLeft,
|
||||||
|
scrollTop,
|
||||||
|
} = viewport
|
||||||
|
|
||||||
|
const enableX = axis === 'x' || axis === 'both'
|
||||||
|
const enableY = axis === 'y' || axis === 'both'
|
||||||
|
const hasOverflowX = enableX && !suppressScrollX && scrollWidth - clientWidth > 1
|
||||||
|
const hasOverflowY = enableY && !suppressScrollY && scrollHeight - clientHeight > 1
|
||||||
|
|
||||||
|
const nextX: AxisScrollbarState = hasOverflowX && clientWidth > 0
|
||||||
|
? (() => {
|
||||||
|
const trackSize = trackX.clientWidth
|
||||||
|
const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackSize)
|
||||||
|
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
|
||||||
|
const scrollRange = Math.max(1, scrollWidth - clientWidth)
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
dragging: scrollbar.x.dragging && dragStateRef.current?.axis === 'x',
|
||||||
|
thumbSize,
|
||||||
|
thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: { ...INITIAL_AXIS_STATE }
|
||||||
|
|
||||||
|
const nextY: AxisScrollbarState = hasOverflowY && clientHeight > 0
|
||||||
|
? (() => {
|
||||||
|
const trackSize = trackY.clientHeight
|
||||||
|
const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackSize)
|
||||||
|
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
|
||||||
|
const scrollRange = Math.max(1, scrollHeight - clientHeight)
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
dragging: scrollbar.y.dragging && dragStateRef.current?.axis === 'y',
|
||||||
|
thumbSize,
|
||||||
|
thumbOffset: (scrollTop / scrollRange) * maxThumbOffset,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: { ...INITIAL_AXIS_STATE }
|
||||||
|
|
||||||
|
setScrollbar({ x: nextX, y: nextY })
|
||||||
|
}, [minThumbSize, scrollbar.x.dragging, scrollbar.y.dragging, suppressScrollX, suppressScrollY])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
const trackX = trackXRef.current
|
||||||
|
const trackY = trackYRef.current
|
||||||
|
if (!viewport || !trackX || !trackY) return
|
||||||
|
|
||||||
|
updateScrollbar()
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateScrollbar()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(viewport)
|
||||||
|
resizeObserver.observe(trackX)
|
||||||
|
resizeObserver.observe(trackY)
|
||||||
|
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
|
||||||
|
viewport.addEventListener('scroll', updateScrollbar, { passive: true })
|
||||||
|
window.addEventListener('resize', updateScrollbar)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
viewport.removeEventListener('scroll', updateScrollbar)
|
||||||
|
window.removeEventListener('resize', updateScrollbar)
|
||||||
|
}
|
||||||
|
}, [children, 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 viewport = viewportRef.current
|
||||||
|
const track = axis === 'x' ? trackXRef.current : trackYRef.current
|
||||||
|
const axisState = scrollbar[axis]
|
||||||
|
if (!viewport || !track) return
|
||||||
|
|
||||||
|
const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight
|
||||||
|
const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize)
|
||||||
|
if (maxThumbOffset <= 0) {
|
||||||
|
if (axis === 'x') viewport.scrollLeft = 0
|
||||||
|
else viewport.scrollTop = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
|
||||||
|
if (axis === 'x') {
|
||||||
|
const scrollRange = Math.max(0, viewport.scrollWidth - viewport.clientWidth)
|
||||||
|
viewport.scrollLeft = progress * scrollRange
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scrollRange = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||||
|
viewport.scrollTop = progress * scrollRange
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
const axisState = scrollbar[axis]
|
||||||
|
if (!axisState.visible) return
|
||||||
|
|
||||||
|
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 track = axis === 'x' ? trackXRef.current : trackYRef.current
|
||||||
|
const dragState = dragStateRef.current
|
||||||
|
if (!track || !dragState || dragState.pointerId !== event.pointerId || dragState.axis !== axis) 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>) => {
|
||||||
|
if (dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current.axis !== axis) 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>) => {
|
||||||
|
const axisState = scrollbar[axis]
|
||||||
|
if (!axisState.visible || event.target !== event.currentTarget) return
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
const nextThumbOffset = axis === 'x'
|
||||||
|
? event.clientX - rect.left - axisState.thumbSize / 2
|
||||||
|
: event.clientY - rect.top - axisState.thumbSize / 2
|
||||||
|
syncScrollFromThumbOffset(axis, nextThumbOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCorner = scrollbar.x.visible && scrollbar.y.visible
|
||||||
|
const scrollbarClassName = [
|
||||||
|
'scrollbar',
|
||||||
|
`scrollbar--${axis}`,
|
||||||
|
className,
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={scrollbarClassName}>
|
||||||
|
<div ref={viewportRef} className="scrollbar__viewport">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(axis === 'y' || axis === 'both') ? (
|
||||||
|
<div
|
||||||
|
ref={trackYRef}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(axis === 'x' || axis === 'both') ? (
|
||||||
|
<div
|
||||||
|
ref={trackXRef}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Scrollbar
|
||||||
@@ -55,11 +55,132 @@ body {
|
|||||||
background: #001529 !important;
|
background: #001529 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-sider.ant-layout-sider-has-trigger {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-sider-inner {
|
.dashboard-sider-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sider-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sider-menu-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sider-menu-scroll .scrollbar__viewport > .ant-menu {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar--y .scrollbar__track--x,
|
||||||
|
.scrollbar--x .scrollbar__track--y {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__viewport {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__viewport::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__track {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__track--y {
|
||||||
|
top: 0;
|
||||||
|
right: 8px;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__track--x {
|
||||||
|
left: 0;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb {
|
||||||
|
position: absolute;
|
||||||
|
min-height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(196, 208, 224, 0.46);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 0.18s ease, background 0.18s ease, width 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb--y {
|
||||||
|
top: 8px;
|
||||||
|
right: 0;
|
||||||
|
width: 6px;
|
||||||
|
min-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb--y::after {
|
||||||
|
inset: 0 -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb--x {
|
||||||
|
left: 8px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 6px;
|
||||||
|
min-height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb--x::after {
|
||||||
|
inset: -6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
|
||||||
|
.scrollbar__track--visible .scrollbar__thumb,
|
||||||
|
.scrollbar__track--dragging .scrollbar__thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__thumb:hover {
|
||||||
|
background: rgba(216, 226, 240, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar__track--dragging .scrollbar__thumb {
|
||||||
|
background: rgba(226, 235, 246, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-brand {
|
.dashboard-brand {
|
||||||
@@ -106,24 +227,31 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-sider-banner {
|
.dashboard-sider-banner {
|
||||||
margin: 12px;
|
flex: 0 0 auto;
|
||||||
|
margin: 12px 12px 0;
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-sider-banner-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-sider-banner-label {
|
.dashboard-sider-banner-label {
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
color: rgba(255, 255, 255, 0.62);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-sider-banner-value {
|
.dashboard-sider-banner-value {
|
||||||
display: block;
|
|
||||||
color: white !important;
|
color: white !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-sider-logout {
|
.dashboard-sider-logout {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.26.0"
|
version = "0.26.1"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user