refactor(devtool): 使用 React 完全重构可视调试工具

This commit is contained in:
XcantloadX 2025-01-29 17:35:00 +08:00
parent c6d80a2215
commit 87427950fc
39 changed files with 6604 additions and 3 deletions

24
kotonebot-devtool/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script>
function loadComponent(componentCode) {
const html = componentCode;
const div = document.createElement('div');
const frag = document.createRange().createContextualFragment(html);
document.body.appendChild(frag);
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3731
kotonebot-devtool/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "kotonebot-devtool",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/node": "^22.12.0",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"immer": "^10.1.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.8",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"use-immer": "^0.11.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import { ErrorBoundary } from './components/Common/ErrorBoundary';
import { Global } from '@emotion/react';
import { globalStyles } from './styles/globalStyles';
export const App: React.FC = () => {
return (
<ErrorBoundary>
<Global styles={globalStyles} />
<RouterProvider router={router} />
</ErrorBoundary>
);
};

View File

@ -0,0 +1,35 @@
import React from 'react';
import styled from '@emotion/styled';
interface ConnectionStatusProps {
connected: boolean;
}
const StatusContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
`;
const StatusDot = styled.div<{ connected: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${props => props.connected ? '#28a745' : '#dc3545'};
`;
const statusMessages = {
connected: 'WebSocket 已连接',
disconnected: 'WebSocket 已断开',
connecting: 'WebSocket 连接中...'
};
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({ connected }) => {
return (
<StatusContainer>
<StatusDot connected={connected} />
<span>{statusMessages[connected ? 'connected' : 'disconnected']}</span>
</StatusContainer>
);
};

View File

@ -0,0 +1,87 @@
import React from 'react';
import styled from '@emotion/styled';
import { Alert, Button } from 'react-bootstrap';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
const ErrorContainer = styled.div`
padding: 2rem;
text-align: center;
`;
const ErrorMessage = styled.pre`
margin: 1rem 0;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
text-align: left;
overflow: auto;
max-height: 200px;
`;
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('组件错误:', error);
console.error('错误详情:', errorInfo);
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null
});
};
render(): React.ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<ErrorContainer>
<Alert variant="danger">
<Alert.Heading></Alert.Heading>
<p></p>
{this.state.error && (
<ErrorMessage>
{this.state.error.toString()}
</ErrorMessage>
)}
<Button
variant="outline-danger"
onClick={this.handleReset}
>
</Button>
</Alert>
</ErrorContainer>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { Spinner } from 'react-bootstrap';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
variant?: string;
text?: string;
fullscreen?: boolean;
}
const SpinnerWrapper = styled.div<{ fullscreen?: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
${props => props.fullscreen && `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
z-index: 9999;
`}
`;
const SpinnerText = styled.div`
margin-top: 1rem;
color: #666;
font-size: 0.9rem;
`;
const sizeMap = {
sm: '1rem',
md: '2rem',
lg: '3rem'
};
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
variant = 'primary',
text,
fullscreen = false
}) => {
return (
<SpinnerWrapper fullscreen={fullscreen}>
<Spinner
animation="border"
variant={variant}
style={{ width: sizeMap[size], height: sizeMap[size] }}
/>
{text && <SpinnerText>{text}</SpinnerText>}
</SpinnerWrapper>
);
};

View File

@ -0,0 +1,227 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import styled from '@emotion/styled';
interface ImageViewerProps {
/** 图片地址 */
image: string;
/** 是否可缩放 */
zoomable?: boolean;
/** 是否可移动 */
movable?: boolean;
/** 最小缩放比例 */
minZoomScale?: number;
/** 最大缩放比例 */
maxZoomScale?: number;
/** 缩放步长 */
zoomStep?: number;
/** 滚轮缩放事件 */
onMouseWheelZoom?: (e: WheelEvent, scale: number) => void;
/** 是否保持变换状态(缩放和位移) */
keepTransforms?: boolean;
}
export interface ImageViewerRef {
reset: (type?: 'zoom' | 'position' | 'all') => void;
setScale: (scale: number) => void;
scale: number;
fit: () => void;
}
const ViewerContainer = styled.div`
height: 100%;
border: 1px solid #ddd;
padding: 20px;
display: flex;
flex-direction: column;
`;
const ImageContainer = styled.div`
flex: 1;
border: 1px solid #eee;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
`;
interface StyledImageProps {
withAnimation?: boolean;
}
const StyledImage = styled.img<StyledImageProps>`
max-width: none;
max-height: none;
transform-origin: center center;
cursor: grab;
user-select: none;
position: relative;
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
&.dragging {
cursor: grabbing;
transition: none;
}
`;
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
({
image,
zoomable: scalable = true,
movable = true,
minZoomScale: minScale = 0.1,
maxZoomScale: maxScale = 5.0,
zoomStep = 0.1,
onMouseWheelZoom,
keepTransforms = false,
}, ref) => {
const [scale, setScale] = useState(1.0);
const [translateX, setTranslateX] = useState(0);
const [translateY, setTranslateY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [useAnimation, setUseAnimation] = useState(false);
const startPosRef = useRef({ x: 0, y: 0 });
const imageRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 重置视图
const reset = (type: 'zoom' | 'position' | 'all' = 'all') => {
setUseAnimation(true);
if (type === 'zoom' || type === 'all') {
setScale(1.0);
}
if (type === 'position' || type === 'all') {
setTranslateX(0);
setTranslateY(0);
}
};
// 适应容器大小
const fit = () => {
if (!imageRef.current || !containerRef.current) return;
const container = containerRef.current;
const img = imageRef.current;
const containerRatio = container.clientWidth / container.clientHeight;
const imageRatio = img.naturalWidth / img.naturalHeight;
let fitScale;
if (imageRatio > containerRatio) {
fitScale = container.clientWidth / img.naturalWidth * 0.9;
} else {
fitScale = container.clientHeight / img.naturalHeight * 0.9;
}
setUseAnimation(false);
setScale(fitScale);
setTranslateX(0);
setTranslateY(0);
};
// 设置缩放
const setScaleWithLimits = useCallback((newScale: number, withAnimation = false) => {
if (!scalable) return;
const limitedScale = Math.min(Math.max(newScale, minScale), maxScale);
setUseAnimation(withAnimation);
setScale(limitedScale);
}, [scalable, minScale, maxScale]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
reset,
setScale: (scale: number) => setScaleWithLimits(scale, true),
get scale() {
return scale;
},
fit
}));
// ===== 事件 =====
// 处理鼠标按下事件
const handleMouseDown = (e: React.MouseEvent) => {
if (!movable) return;
setIsDragging(true);
startPosRef.current = {
x: e.clientX - translateX,
y: e.clientY - translateY
};
};
// 处理鼠标移动事件
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !movable) return;
setUseAnimation(false);
setTranslateX(e.clientX - startPosRef.current.x);
setTranslateY(e.clientY - startPosRef.current.y);
};
// 处理鼠标松开事件
const handleMouseUp = () => {
setIsDragging(false);
};
// 处理滚轮缩放
const handleWheel = useCallback((e: WheelEvent) => {
if (!scalable) return;
e.preventDefault();
const delta = e.deltaY;
setScaleWithLimits(scale + (delta > 0 ? -zoomStep : zoomStep), true);
onMouseWheelZoom?.(e, scale + (delta > 0 ? -zoomStep : zoomStep));
}, [scalable, scale, setScaleWithLimits, zoomStep, onMouseWheelZoom]);
// 图片加载完成后自动适应容器大小
const handleImageLoad = () => {
if (!keepTransforms)
fit();
};
// 监听
useEffect(() => {
const handleGlobalMouseUp = () => {
if (isDragging) {
setIsDragging(false);
}
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [isDragging]);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheel);
};
}, [handleWheel]);
// ===== JSX =====
return (
<ViewerContainer>
<ImageContainer
ref={containerRef}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<StyledImage
ref={imageRef}
src={image}
onLoad={handleImageLoad}
onMouseDown={handleMouseDown}
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
className={isDragging ? 'dragging' : ''}
withAnimation={useAnimation}
style={{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
}}
/>
</ImageContainer>
</ViewerContainer>
);
}
);
export default ImageViewer;

View File

@ -0,0 +1,332 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import ImageViewer, { ImageViewerRef } from './ImageViewer';
import { css } from '@emotion/react';
export interface ImageGroup {
mainIndex: number;
images: string[];
}
interface MultipleImagesViewerProps {
/** 当前组 */
currentGroup: ImageGroup;
/** 组数 */
groupCount: number;
/** 当前组索引 */
groupIndex: number;
/** 当前图片索引 */
imageIndex: number;
/** 跳转到指定组回调 */
onGotoGroup: (groupIndex: number) => void;
/** 跳转到指定图片回调 */
onGotoImage: (imageIndex: number) => void;
}
const ViewerContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
`;
const MainContent = styled.div`
flex: 1;
min-height: 0; /* 重要:防止内容溢出 */
position: relative;
`;
const ControlsContainer = styled.div`
padding: 10px;
background-color: #f8f9fa;
border-top: 1px solid #ddd;
`;
const Toolbar = styled.div`
padding: 6px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 10px;
`;
const SliderContainer = styled.div`
padding: 0 20px;
margin-bottom: 10px;
`;
const Slider = styled.input`
width: 100%;
`;
const NavigationControls = styled.div`
display: flex;
align-items: center;
gap: 15px;
justify-content: center;
`;
const ZoomControls = styled.div`
display: flex;
align-items: center;
gap: 10px;
& > span {
margin: 0 10px;
}
`;
const MultipleImagesViewer: React.FC<MultipleImagesViewerProps> = ({
currentGroup,
groupCount,
groupIndex,
imageIndex,
onGotoGroup = () => {},
onGotoImage = () => {},
}) => {
const [isViewLocked, setIsViewLocked] = useState(false);
const [scale, setScale] = useState(1.0);
const imageViewerRef = useRef<ImageViewerRef>(null);
// 处理缩放控制
const handleZoomIn = () => {
setScale((prev) => prev + 0.1);
imageViewerRef.current?.setScale(scale + 0.1);
};
const handleZoomOut = () => {
setScale((prev) => prev - 0.1);
imageViewerRef.current?.setScale(scale - 0.1);
};
const handleResetZoom = () => {
setScale(1);
imageViewerRef.current?.reset('zoom');
};
const handleFit = () => {
imageViewerRef.current?.fit();
setScale(imageViewerRef.current?.scale || 1);
};
const handleUserMouseWheelZoom = (e: WheelEvent, scale: number) => {
setScale(scale);
};
// 处理下载
const handleDownload = useCallback(() => {
const currentImages = currentGroup?.images || [];
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
currentImages.forEach((url, index) => {
const link = document.createElement('a');
link.href = url;
const fileName = currentImages.length > 1
? `image_${groupIndex + 1}_${index + 1}_${timestamp}.png`
: `image_${groupIndex + 1}_${timestamp}.png`;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}, [currentGroup, groupIndex]);
// 处理滑块变化
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
onGotoGroup(parseInt(e.target.value));
};
// 处理视图锁定
const handleViewLockChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsViewLocked(e.target.checked);
};
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
switch (e.key) {
case 'ArrowLeft':
onGotoGroup(groupIndex - 1);
break;
case 'ArrowRight':
onGotoGroup(groupIndex + 1);
break;
case 'Home':
onGotoGroup(0);
break;
case 'End':
onGotoGroup(groupCount - 1);
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [groupCount, groupIndex, onGotoGroup]);
const _currentGroup = currentGroup;
const _currentImage = _currentGroup?.images?.[imageIndex];
return (
<ViewerContainer>
<MainContent>
{_currentImage && (
<ImageViewer
ref={imageViewerRef}
image={_currentImage}
zoomable={true}
movable={true}
keepTransforms={isViewLocked}
onMouseWheelZoom={handleUserMouseWheelZoom}
/>
)}
</MainContent>
<ControlsContainer>
<Toolbar>
<ZoomControls>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={handleZoomOut}
title="缩小"
>
<i className="bi bi-zoom-out" />
</button>
<span>{Math.round(scale * 100)}%</span>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={handleZoomIn}
title="放大"
>
<i className="bi bi-zoom-in" />
</button>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={handleResetZoom}
title="重置缩放"
>
<i className="bi bi-arrow-counterclockwise" />
</button>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={handleFit}
title="适应窗口"
>
<i className="bi bi-arrows-angle-contract" />
</button>
</ZoomControls>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={handleDownload}
title="下载图片组"
>
<i className="bi bi-download" />
</button>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
id="lockViewCheckbox"
checked={isViewLocked}
onChange={handleViewLockChange}
/>
<label className="form-check-label" htmlFor="lockViewCheckbox">
</label>
</div>
<div css={css`display: flex;`}>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => {
if (imageIndex > 0) {
onGotoImage(imageIndex - 1);
}
}}
disabled={imageIndex <= 0}
title="上一张图片"
>
<i className="bi bi-arrow-left-short" />
</button>
<span className="mx-2">
{imageIndex + 1}/{ currentGroup?.images?.length }
</span>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => {
if (imageIndex < currentGroup?.images?.length - 1) {
onGotoImage(imageIndex + 1);
}
}}
disabled={imageIndex >= currentGroup?.images?.length - 1}
title="下一张图片"
>
<i className="bi bi-arrow-right-short" />
</button>
</div>
</Toolbar>
<SliderContainer>
<Slider
type="range"
className="form-range"
min={0}
max={groupCount - 1}
value={groupIndex}
onChange={handleSliderChange}
/>
</SliderContainer>
<NavigationControls>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => onGotoGroup(0)}
disabled={groupIndex <= 0}
title="第一组"
>
<i className="bi bi-chevron-bar-left" />
</button>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => onGotoGroup(groupIndex - 1)}
disabled={groupIndex <= 0}
title="上一组"
>
<i className="bi bi-chevron-left" />
</button>
<span>
{groupIndex + 1} / {groupCount}
</span>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => onGotoGroup(groupIndex + 1)}
disabled={groupIndex >= groupCount - 1}
title="下一组"
>
<i className="bi bi-chevron-right" />
</button>
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
onClick={() => onGotoGroup(groupCount - 1)}
disabled={groupIndex >= groupCount - 1}
title="最后一组"
>
<i className="bi bi-chevron-bar-right" />
</button>
</NavigationControls>
</ControlsContainer>
</ViewerContainer>
);
};
export default MultipleImagesViewer;

View File

@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import styled from '@emotion/styled';
import { useResizePanel } from '../hooks/useResizePanel';
interface SplitableProps {
left: React.ReactNode;
right: React.ReactNode;
/** 默认是否折叠右侧面板 */
defaultCollapsed?: boolean;
/** 折叠状态改变时的回调 */
onCollapsedChange?: (collapsed: boolean) => void;
}
const Container = styled.div`
position: relative;
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
`;
const LeftPanel = styled.div`
flex: 1;
min-width: 0;
`;
const RightPanel = styled.div<{ $width: number; $collapsed: boolean }>`
position: relative;
width: ${props => props.$collapsed ? '0' : `${props.$width}px`};
height: 100%;
background-color: #fff;
border-left: 1px solid #dee2e6;
overflow: hidden;
`;
const ResizeHandle = styled.div<{ $isResizing: boolean }>`
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background-color: ${props => props.$isResizing ? '#0d6efd' : 'transparent'};
transition: background-color 0.2s ease;
cursor: col-resize;
z-index: 1;
&:hover {
background-color: #0d6efd;
}
`;
const ToggleButton = styled.button<{ $collapsed: boolean }>`
position: absolute;
right: 1rem;
top: 1rem;
z-index: 2;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
border-radius: 0.2rem;
border: 1px solid #6c757d;
background-color: transparent;
color: #6c757d;
cursor: pointer;
transition: transform 0.3s ease;
transform: rotate(${props => props.$collapsed ? 180 : 0}deg);
&:hover {
background-color: #6c757d;
color: #fff;
}
`;
export const Splitable: React.FC<SplitableProps> = ({
left,
right,
defaultCollapsed = false,
onCollapsedChange
}) => {
const {
width,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useResizePanel();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
handleResizeMove(e);
};
const handleMouseUp = () => {
handleResizeEnd();
};
if (isResizing) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, handleResizeMove, handleResizeEnd]);
const handleToggle = () => {
setCollapsed(prev => {
const newCollapsed = !prev;
onCollapsedChange?.(newCollapsed);
return newCollapsed;
});
};
return (
<Container>
<ToggleButton
onClick={handleToggle}
$collapsed={collapsed}
>
<i className="bi bi-chevron-right" />
</ToggleButton>
<LeftPanel>
{left}
</LeftPanel>
<RightPanel $width={width} $collapsed={collapsed}>
<ResizeHandle
onMouseDown={handleResizeStart}
$isResizing={isResizing}
/>
{right}
</RightPanel>
</Container>
);
};

View File

@ -0,0 +1,267 @@
<style id="kbd-color-style">
.kbd-color-wrapper {
position: relative;
}
.kbd-color-square {
width: var(--size, 24px);
height: var(--size, 24px);
border: 2px solid #888;
cursor: pointer;
}
.kbd-color-tooltip {
position: absolute;
padding: 8px;
border-radius: 4px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 12px;
z-index: 1000;
white-space: nowrap;
top: calc(100% + 5px);
left: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.3s;
}
.kbd-color-tooltip.show {
opacity: 1;
visibility: visible;
transition: opacity 0.2s ease, visibility 0s linear 0s;
}
.kbd-color-tooltip.hide {
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
}
@media (prefers-color-scheme: light) {
.kbd-color-tooltip {
background: #f3f3f3;
border: 1px solid #d4d4d4;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.kbd-color-copy-btn {
background: #007acc;
color: white;
}
.kbd-color-copy-btn:hover {
background: #005999;
}
}
@media (prefers-color-scheme: dark) {
.kbd-color-tooltip {
background: #252526;
border: 1px solid #454545;
color: #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.kbd-color-copy-btn {
background: #0e639c;
color: white;
}
.kbd-color-copy-btn:hover {
background: #1177bb;
}
}
.kbd-color-tooltip-row {
display: flex;
align-items: center;
margin: 4px 0;
}
.kbd-color-copy-btn {
margin-left: 8px;
padding: 2px 6px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
</style>
<template id="kbd-color-template">
<div class="kbd-color-wrapper">
<div class="kbd-color-square"></div>
<div class="kbd-color-tooltip">
<div class="kbd-color-tooltip-row">
<span class="kbd-color-hex-value"></span>
<button class="kbd-color-copy-btn kbd-color-copy-hex">复制</button>
</div>
<div class="kbd-color-tooltip-row">
<span class="kbd-color-rgb-value"></span>
<button class="kbd-color-copy-btn kbd-color-copy-rgb">复制</button>
</div>
</div>
</div>
</template>
<script>
console.log(111);
/**
* @class KbdColor
* @extends HTMLElement
* @description 自定义Web组件用于显示颜色预览和颜色值。支持十六进制和RGB格式的颜色显示及复制功能。
* @property {HTMLElement} colorSquare - 显示颜色的方块元素
* @property {HTMLElement} tooltip - 显示颜色值的提示框元素
* @property {HTMLElement} hexSpan - 显示十六进制颜色值的元素
* @property {HTMLElement} rgbSpan - 显示RGB颜色值的元素
* @property {number} hideTimeout - 控制tooltip隐藏的定时器ID
*/
class KbdColor extends HTMLElement {
/**
* @constructor
* @description 初始化KbdColor组件设置DOM结构和事件监听
*/
constructor() {
super();
// 创建 shadow root
const shadow = this.attachShadow({ mode: 'open' });
// 克隆样式
const style = document.getElementById('kbd-color-style').cloneNode(true);
// 克隆模板内容
const template = document.getElementById('kbd-color-template');
const content = template.content.cloneNode(true);
// 将样式和内容添加到 shadow root
shadow.appendChild(style);
shadow.appendChild(content);
// 获取 shadow DOM 中的元素
this.colorSquare = shadow.querySelector('.kbd-color-square');
this.tooltip = shadow.querySelector('.kbd-color-tooltip');
this.hexSpan = shadow.querySelector('.kbd-color-hex-value');
this.rgbSpan = shadow.querySelector('.kbd-color-rgb-value');
this.hideTimeout = null;
this.setupEvents();
this.updateColor(this.getAttribute('color') || '#000000');
this.updateSize(this.getAttribute('size') || '24px');
}
/**
* @private
* @description 设置组件的事件监听器,包括鼠标悬停和复制按钮的点击事件
*/
setupEvents() {
const showTooltip = () => {
clearTimeout(this.hideTimeout);
this.tooltip.classList.add('show');
};
const hideTooltip = () => {
this.hideTimeout = setTimeout(() => {
this.tooltip.classList.remove('show');
}, 100);
};
this.colorSquare.addEventListener('mouseenter', showTooltip);
this.colorSquare.addEventListener('mouseleave', hideTooltip);
this.tooltip.addEventListener('mouseenter', showTooltip);
this.tooltip.addEventListener('mouseleave', hideTooltip);
this.shadowRoot.querySelector('.kbd-color-copy-hex').addEventListener('click', (e) => {
navigator.clipboard.writeText(this.getAttribute('color'));
const btn = e.target;
const originalText = btn.textContent;
btn.textContent = '已复制';
setTimeout(() => {
btn.textContent = originalText;
}, 1000);
});
this.shadowRoot.querySelector('.kbd-color-copy-rgb').addEventListener('click', (e) => {
navigator.clipboard.writeText(this.rgbSpan.textContent);
const btn = e.target;
const originalText = btn.textContent;
btn.textContent = '已复制';
setTimeout(() => {
btn.textContent = originalText;
}, 1000);
});
}
/**
* @static
* @returns {string[]} 返回需要观察的属性列表
*/
static get observedAttributes() {
return ['color', 'size'];
}
/**
* @param {string} name - 属性名称
* @param {string} oldValue - 属性的旧值
* @param {string} newValue - 属性的新值
* @description 当观察的属性发生变化时调用的回调函数
*/
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'color') {
this.updateColor(newValue);
} else if (name === 'size') {
this.updateSize(newValue);
}
}
/**
* @param {string} size - 要设置的大小值,可以是数字或带单位的字符串
* @description 更新颜色方块的大小
*/
updateSize(size) {
if (!size) return;
const sizeValue = /^\d+$/.test(size) ? `${size}px` : size;
this.style.setProperty('--size', sizeValue);
}
/**
* @param {string} color - 要设置的颜色值,格式为十六进制(如 #FF0000
* @description 更新颜色方块的颜色并更新显示的颜色值
*/
updateColor(color) {
if (!color) return;
this.colorSquare.style.backgroundColor = color;
this.hexSpan.textContent = color.toUpperCase();
// Convert hex to RGB
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
this.rgbSpan.textContent = `rgb(${r}, ${g}, ${b})`;
}
}
customElements.define('kbd-color', KbdColor);
console.log('KbdColor loaded', KbdColor);
// 示例用法
/*
$(document).ready(function() {
$('body').append(`
<h3>颜色预览示例:</h3>
<p>默认大小 (24px)</p>
<kbd-color color="#FF5733"></kbd-color>
<kbd-color color="#33FF57"></kbd-color>
<kbd-color color="#3357FF"></kbd-color>
<p>自定义大小:</p>
<kbd-color color="#FF5733" size="16px"></kbd-color>
<kbd-color color="#33FF57" size="32"></kbd-color>
<kbd-color color="#3357FF" size="48px"></kbd-color>
`);
});
*/
</script>

View File

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Spinner } from 'react-bootstrap';
import styled from '@emotion/styled';
const SpinnerOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
`;
const SpinnerText = styled.div`
margin-top: 1rem;
color: #666;
font-size: 0.9rem;
white-space: pre-wrap;
`;
interface SpinnerComponentProps {
message?: string;
show: boolean;
}
const SpinnerComponent: React.FC<SpinnerComponentProps> = ({
message,
show
}) => {
if (!show) return null;
return (
<SpinnerOverlay>
<Spinner
animation="border"
variant="primary"
style={{ width: '3rem', height: '3rem' }}
/>
{message && <SpinnerText>{message}</SpinnerText>}
</SpinnerOverlay>
);
};
// 创建一个单例来管理 spinner root
const createSpinnerRoot = () => {
const containerId = 'fullscreen-spinner-root';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
return createRoot(container);
};
let spinnerRoot: ReturnType<typeof createRoot> | null = null;
export function useFullscreenSpinner() {
const [spinnerProps, setSpinnerProps] = useState<SpinnerComponentProps>({
show: false,
message: undefined
});
// 在第一次使用时创建 root
useEffect(() => {
if (!spinnerRoot) {
spinnerRoot = createSpinnerRoot();
}
// 在组件卸载时,如果没有其他组件使用 spinner则清理 root
return () => {
if (spinnerRoot && !spinnerProps.show) {
// 使用 requestAnimationFrame 确保在下一帧进行卸载
requestAnimationFrame(() => {
const container = document.getElementById('fullscreen-spinner-root');
if (container) {
spinnerRoot?.unmount();
container.remove();
spinnerRoot = null;
}
});
}
};
}, [spinnerProps.show]);
// 当 props 改变时更新 root
useEffect(() => {
spinnerRoot?.render(<SpinnerComponent {...spinnerProps} />);
}, [spinnerProps]);
const show = useCallback((message?: string) => {
setSpinnerProps({
show: true,
message
});
}, []);
const hide = useCallback(() => {
setSpinnerProps(prev => ({ ...prev, show: false }));
}, []);
return {
show,
hide
};
}

View File

@ -0,0 +1,127 @@
import React, { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Modal } from 'react-bootstrap';
interface MessageBoxProps {
title: string;
text: string;
onClose: (result: string) => void;
type: 'yesno' | 'ok' | 'confirmcancel';
show: boolean;
}
const MessageBoxComponent: React.FC<MessageBoxProps> = ({
title,
text,
onClose,
type,
show
}) => {
const handleClose = useCallback((result: string) => {
onClose(result);
}, [onClose]);
const renderButtons = () => {
switch (type) {
case 'yesno':
return (
<>
<button className="btn btn-primary" onClick={() => handleClose('yes')}>
</button>
<button className="btn btn-secondary" onClick={() => handleClose('no')}>
</button>
</>
);
case 'ok':
return (
<button className="btn btn-primary" onClick={() => handleClose('ok')}>
</button>
);
case 'confirmcancel':
return (
<>
<button className="btn btn-primary" onClick={() => handleClose('confirm')}>
</button>
<button className="btn btn-secondary" onClick={() => handleClose('cancel')}>
</button>
</>
);
}
};
return createPortal(
<Modal show={show} onHide={() => handleClose('cancel')} centered>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{text}</Modal.Body>
<Modal.Footer>
{renderButtons()}
</Modal.Footer>
</Modal>,
document.body
);
};
interface MessageBoxOptions {
title: string;
text: string;
}
export function useMessageBox() {
const [modalProps, setModalProps] = useState<Omit<MessageBoxProps, 'onClose'> | null>(null);
const resolveRef = useRef<((value: string) => void) | null>(null);
const showModal = useCallback((
type: 'yesno' | 'ok' | 'confirmcancel',
options: MessageBoxOptions
): Promise<string> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setModalProps({
type,
title: options.title,
text: options.text,
show: true
});
});
}, []);
const handleClose = useCallback((result: string) => {
setModalProps(prev => prev ? { ...prev, show: false } : null);
// 等待动画结束后再resolve
if (resolveRef.current) {
resolveRef.current(result);
resolveRef.current = null;
}
}, []);
const yesNo = useCallback((options: MessageBoxOptions) => {
return showModal('yesno', options) as Promise<'yes' | 'no'>;
}, [showModal]);
const ok = useCallback((options: MessageBoxOptions) => {
return showModal('ok', options) as Promise<'ok'>;
}, [showModal]);
const confirmCancel = useCallback((options: MessageBoxOptions) => {
return showModal('confirmcancel', options) as Promise<'confirm' | 'cancel'>;
}, [showModal]);
return {
yesNo,
ok,
confirmCancel,
MessageBoxComponent: modalProps ? (
<MessageBoxComponent {...modalProps} onClose={handleClose} />
) : null
};
}

View File

@ -0,0 +1,60 @@
import { useCallback, useRef, useState } from 'react';
interface UseResizePanelProps {
minWidth?: number;
maxWidth?: number;
defaultWidth?: number;
onWidthChange?: (width: number) => void;
}
export const useResizePanel = ({
minWidth = 200,
maxWidth = 800,
defaultWidth = 400,
onWidthChange
}: UseResizePanelProps = {}) => {
const [width, setWidth] = useState(defaultWidth);
const [isResizing, setIsResizing] = useState(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(0);
const handleResizeStart = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [width]);
const handleResizeMove = useCallback((event: MouseEvent) => {
if (!isResizing) return;
const dx = startXRef.current - event.clientX;
const newWidth = Math.min(Math.max(startWidthRef.current + dx, minWidth), maxWidth);
setWidth(newWidth);
onWidthChange?.(newWidth);
}, [isResizing, minWidth, maxWidth, onWidthChange]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
const togglePanel = useCallback(() => {
const newWidth = width === 0 ? defaultWidth : 0;
setWidth(newWidth);
onWidthChange?.(newWidth);
}, [width, defaultWidth, onWidthChange]);
return {
width,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
togglePanel
};
};

View File

@ -0,0 +1,12 @@
/* 结果表格。用在 Python 代码里 */
.result-table {
text-align: center;
width: 100%;
border-collapse: collapse;
border: 1px solid #ddd;
}
.result-table td {
border: 1px solid #ddd;
padding: 5px;
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './index.css';
import './components/kbd-color.component.html';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,241 @@
import React, { useRef } from 'react';
import styled from '@emotion/styled';
import ImageViewer, { ImageViewerRef } from '../components/ImageViewer';
import MultipleImagesViewer from '../components/MutipleImagesViewer';
import { useMessageBox } from '../hooks/useMessageBox';
import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner';
const DemoContainer = styled.div`
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
width: 80%;
margin: 0 auto;
`;
const ViewerContainer = styled.div`
height: 500px;
border: 1px solid #ccc;
border-radius: 4px;
`;
const ControlPanel = styled.div`
display: flex;
gap: 10px;
align-items: center;
`;
const Button = styled.button`
padding: 8px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
&:hover {
background: #f5f5f5;
}
`;
const Section = styled.section`
margin-bottom: 40px;
`;
function Demo() {
const viewerRef = useRef<ImageViewerRef>(null);
const messageBox = useMessageBox();
const spinner = useFullscreenSpinner();
// 示例图片,这里使用一个在线图片
const demoImage = 'https://picsum.photos/800/600';
// 多图片查看器的示例数据
const demoImageGroups = [
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=1',
'https://picsum.photos/800/600?random=2',
'https://picsum.photos/800/600?random=3'
]
},
{
mainIndex: 1,
images: [
'https://picsum.photos/800/600?random=4',
'https://picsum.photos/800/600?random=5'
]
},
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=6',
'https://picsum.photos/800/600?random=7',
'https://picsum.photos/800/600?random=8',
'https://picsum.photos/800/600?random=9'
]
}
];
const handleReset = () => {
viewerRef.current?.reset('all');
};
const handleResetPosition = () => {
viewerRef.current?.reset('position');
};
const handleResetZoom = () => {
viewerRef.current?.reset('zoom');
};
const handleFit = () => {
viewerRef.current?.fit();
};
const handleZoomIn = () => {
if (viewerRef.current) {
viewerRef.current.setScale(viewerRef.current.scale + 0.1);
}
};
const handleZoomOut = () => {
if (viewerRef.current) {
viewerRef.current.setScale(viewerRef.current.scale - 0.1);
}
};
const handleShowYesNo = async () => {
const result = await messageBox.yesNo({
title: '确认操作',
text: '您确定要执行此操作吗?'
});
alert(`您选择了: ${result === 'yes' ? '是' : '否'}`);
};
const handleShowOk = async () => {
await messageBox.ok({
title: '操作成功',
text: '数据已成功保存!'
});
};
const handleShowConfirmCancel = async () => {
const result = await messageBox.confirmCancel({
title: '删除确认',
text: '此操作将永久删除所选项目,是否继续?'
});
alert(`您选择了: ${result === 'confirm' ? '确认' : '取消'}`);
};
const handleShowSpinner = () => {
spinner.show('加载中...');
setTimeout(() => {
spinner.hide();
}, 3000);
};
const handleShowSpinnerWithoutMessage = () => {
spinner.show();
setTimeout(() => {
spinner.hide();
}, 3000);
};
return (
<DemoContainer>
{messageBox.MessageBoxComponent}
<Section>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowYesNo}>/</Button>
<Button onClick={handleShowOk}></Button>
<Button onClick={handleShowConfirmCancel}>/</Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li>"是/否对话框"/</li>
<li>"提示对话框"</li>
<li>"确认/取消对话框"/</li>
</ul>
</div>
</Section>
<Section>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowSpinner}></Button>
<Button onClick={handleShowSpinnerWithoutMessage}></Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li>3</li>
<li></li>
<li></li>
</ul>
</div>
</Section>
<Section>
<h2></h2>
<ViewerContainer>
<ImageViewer
ref={viewerRef}
image={demoImage}
zoomable={true}
movable={true}
/>
</ViewerContainer>
<ControlPanel>
<Button onClick={handleZoomIn}></Button>
<Button onClick={handleZoomOut}></Button>
<Button onClick={handleFit}></Button>
<Button onClick={handleReset}></Button>
<Button onClick={handleResetPosition}></Button>
<Button onClick={handleResetZoom}></Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li></li>
<li></li>
<li>"适应"使</li>
<li>"重置位置"</li>
<li>"重置缩放"</li>
<li>"完全重置"</li>
</ul>
</div>
</Section>
<Section>
<h2></h2>
<ViewerContainer>
<MultipleImagesViewer images={demoImageGroups} />
</ViewerContainer>
<div>
<h3>使</h3>
<ul>
<li>使</li>
<li>使</li>
<li>Home/End /</li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</Section>
</DemoContainer>
);
}
export default Demo;

View File

@ -0,0 +1,81 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { useDebugStore } from '../../store/debugStore';
interface InfoPanelProps {
/** 信息名称 */
name: string;
/** 信息具体内容 */
details?: string;
/** 图片映射 */
imagesMap?: Map<string, string>;
}
const PanelContainer = styled.div`
position: relative;
height: 100%;
background-color: #fff;
overflow: hidden;
`;
const ScrollContainer = styled.div`
height: 100%;
overflow-y: auto;
overflow-x: hidden;
`;
const MethodContainer = styled.div`
padding: 1rem;
border-bottom: 1px solid #dee2e6;
`;
const MethodName = styled.h2`
margin: 0 0 1rem 0;
font-weight: 600;
color: #495057;
`;
const MethodDetails = styled.div`
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
`;
// 解析 [img]url[/img] 标签
function parseImgTags(text: string, img2urlCallback = (k: string) => '/api/read_memory?key=' + k): string {
// 解析 [img] 标签
text = text.replace(/\[img\](.*?)\[\/img\]/g, (match, p1) => {
return `<img src="${img2urlCallback(p1)}" alt="image">`;
});
return text;
}
function InfoPanel({
name,
details,
imagesMap
}: InfoPanelProps) {
const host = useDebugStore(state => state.host);
const img2urlCallback = useCallback((k: string) => {
if (imagesMap) {
return imagesMap.get(k) ?? `http://${host}/api/read_memory?key=${k}`;
}
return `http://${host}/api/read_memory?key=${k}`;
}, [imagesMap, host]);
return (
<PanelContainer>
<ScrollContainer>
<MethodContainer>
<MethodName>{name}</MethodName>
<MethodDetails>
<div dangerouslySetInnerHTML={{ __html: details ? parseImgTags(details, img2urlCallback) : '' }} />
</MethodDetails>
</MethodContainer>
</ScrollContainer>
</PanelContainer>
);
};
export default InfoPanel;

View File

@ -0,0 +1,281 @@
import React, { useState, useCallback, useEffect } from 'react';
import styled from '@emotion/styled';
import InfoPanel from './InfoPanel';
import { ConnectionStatus } from '../../components/Common/ConnectionStatus';
import MultipleImagesViewer from '../../components/MutipleImagesViewer';
import { Splitable } from '../../components/Splitable';
import { ConnectionStatusEvent, VisualEvent, VisualEventData } from '../../utils/debugClient';
import { useImmer } from 'use-immer';
import { useDebugClient, useDebugStore } from '../../store/debugStore';
import { Button, FormCheck } from 'react-bootstrap';
import { useMessageBox } from '../../hooks/useMessageBox';
import { useFullscreenSpinner } from '../../hooks/useFullscreenSpinner';
function readLocalDump(files: FileList, reportProgress?: (message: string, current: number, total: number) => void) {
return new Promise<{records: VisualEventData[], images: Map<string, string>}>((resolve, reject) => {
// 找到JSON文件
const jsonFile = Array.from(files).find(f => f.name.endsWith('.json'));
if (!jsonFile) {
reject(new Error('未找到 JSON 文件'));
return;
}
// 读取JSON文件
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const lines = text.split('\n').filter(line => line.trim());
const records: VisualEventData[] = [];
// 读取所有图像文件
const imageFiles = Array.from(files).filter(f =>
f.type.startsWith('image/') || f.name.endsWith('.png') || f.name.endsWith('.jpg')
);
const imageMap = new Map<string, string>();
// 将所有图像转换为Data URL
let current = 0;
const total = imageFiles.length;
for (const imageFile of imageFiles) {
reportProgress?.(imageFile.name, current, total);
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsDataURL(imageFile);
});
imageMap.set(imageFile.name.replace(/\.[^/.]+$/, ''), dataUrl);
current++;
}
// 解析JSON并替换图像路径
for (const line of lines) {
try {
const data = JSON.parse(line);
if (!data.image || !data.name || !data.details)
throw new Error('JSON文件格式错误');
records.push(data);
} catch {
console.error('无效的JSON行:', line);
}
}
resolve({records, images: imageMap});
} catch (err) {
console.error('读取dump文件时出错:', err);
reject(err);
}
};
reader.onerror = () => reject(new Error('读取JSON文件失败'));
reader.readAsText(jsonFile);
});
}
const LayoutContainer = styled.div`
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
`;
const MainContent = styled.div`
flex: 1;
display: flex;
overflow: hidden;
`;
const ViewerContainer = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
`;
const ToolbarContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
height: 42px;
`;
const ToolbarButton = styled(Button)`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
i {
font-size: 1rem;
}
`;
export const MainLayout: React.FC = () => {
const client = useDebugClient();
const host = useDebugStore(state => state.host);
const [isConnected, setIsConnected] = useState(false);
const [records, updateRecords] = useImmer<VisualEventData[]>([]);
const [index, setIndex] = useState(0);
const [imageIndex, setImageIndex] = useState(0);
const [isLocalMode, setIsLocalMode] = useState(false);
const [localImageMap, setLocalImageMap] = useState<Map<string, string>>();
const { ok, MessageBoxComponent } = useMessageBox();
const spinner = useFullscreenSpinner();
// 处理本地文件打开
const handleOpenLocal = useCallback(async () => {
try {
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true; // 允许选择文件夹
// @ts-expect-error https://stackoverflow.com/questions/72787050/typescript-upload-directory-property-directory-does-not-exist-on-type
input.directory = true; // 允许选择文件夹
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
if (!files || files.length === 0) return;
try {
const {records, images} = await readLocalDump(files, (message, current, total) => {
spinner.show(`读取文件夹中... ${current}/${total}`);
});
spinner.hide();
setLocalImageMap(images);
console.log('VisualEventData from local directory:', records);
if (records.length > 0) {
setIsLocalMode(true);
updateRecords(() => records);
setIndex(0);
setImageIndex(0);
} else {
await ok({
title: '提示',
text: '文件夹中没有找到有效的记录'
});
}
} catch (err) {
console.error('Error reading local directory:', err);
await ok({
title: '错误',
text: '读取文件夹失败:' + (err as Error).message
});
}
};
input.click();
} catch (err) {
console.error('Error opening local directory:', err);
await ok({
title: '错误',
text: '打开文件夹失败:' + (err as Error).message
});
}
}, [spinner, localImageMap, updateRecords, ok]);
// 清除记录
const handleClearRecords = useCallback(() => {
updateRecords(() => []);
setIndex(0);
setImageIndex(0);
}, [updateRecords]);
// WS 客户端初始化
const handleVisualEvent = useCallback((event: VisualEvent) => {
if (isLocalMode)
return;
updateRecords(draft => {
draft.push(event.data);
});
}, [updateRecords, isLocalMode]);
const handleConnectionStatus = useCallback((event: ConnectionStatusEvent) => {
setIsConnected(event.connected);
}, [setIsConnected]);
useEffect(() => {
const _client = client;
_client.addEventListener('connectionStatus', handleConnectionStatus);
_client.addEventListener('visual', handleVisualEvent);
return () => {
_client.removeEventListener('connectionStatus', handleConnectionStatus);
_client.removeEventListener('visual', handleVisualEvent);
};
}, [client, handleVisualEvent, handleConnectionStatus]);
useEffect(() => {
if (index === records.length - 1) {
setIndex(records.length - 1);
}
}, [records, index]);
return (
<LayoutContainer>
{MessageBoxComponent}
<ToolbarContainer>
{!isLocalMode && <ConnectionStatus connected={isConnected} />}
<FormCheck
type="switch"
checked={isLocalMode}
onClick={() => setIsLocalMode(!isLocalMode)}
label="本地模式"
/>
<ToolbarButton
variant="outline-primary"
onClick={handleOpenLocal}
>
<i className="bi bi-folder2-open"></i>
</ToolbarButton>
<ToolbarButton
variant="outline-danger"
onClick={handleClearRecords}
>
<i className="bi bi-trash"></i>
</ToolbarButton>
</ToolbarContainer>
<MainContent>
<Splitable
left={
<ViewerContainer>
<MultipleImagesViewer
currentGroup={{
mainIndex: 0,
images: !isLocalMode ?
(records[index]?.image.value.map(item => 'http://' + host + '/api/read_memory?key=' + item))
: (records[index]?.image.value.map(item => localImageMap?.get(item) ?? 'error')),
}}
groupIndex={index}
imageIndex={imageIndex}
groupCount={records.length}
onGotoGroup={(i) => {
setIndex(i);
setImageIndex(0);
}}
onGotoImage={setImageIndex}
/>
</ViewerContainer>
}
right={
<InfoPanel
name={records[index]?.name}
details={records[index]?.details}
imagesMap={isLocalMode ? localImageMap : undefined}
/>
}
/>
</MainContent>
</LayoutContainer>
);
};

View File

@ -0,0 +1,10 @@
import React from 'react';
import { MainLayout } from './DumpViewer/MainLayout';
export const Home: React.FC = () => {
return (
<MainLayout
/>
);
};

View File

@ -0,0 +1,14 @@
import { createBrowserRouter } from 'react-router-dom';
import { Home } from './pages/Home';
import Demo from './pages/Demo';
export const router = createBrowserRouter([
{
path: '/',
element: <Home />,
},
{
path: '/demo',
element: <Demo />,
},
]);

View File

@ -0,0 +1,23 @@
import { create } from 'zustand';
import { KotoneDebugClient } from '../utils/debugClient';
interface DebugState {
client: KotoneDebugClient | null;
host: string;
}
export const useDebugStore = create<DebugState>(() => ({
client: null,
host: '127.0.0.1:8000',
}));
export function useDebugClient() {
const client = useDebugStore(state => state.client);
const host = useDebugStore(state => state.host);
if (!client) {
const _client = new KotoneDebugClient(host);
useDebugStore.setState({ client: _client });
return _client;
}
return client;
}

View File

@ -0,0 +1,39 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist } from 'zustand/middleware';
interface Settings {
baseUrl: string;
infoPanelWidth: number;
theme: 'light' | 'dark';
}
interface SettingsState extends Settings {
updateSettings: (settings: Partial<Settings>) => void;
resetSettings: () => void;
}
const DEFAULT_SETTINGS: Settings = {
baseUrl: 'http://localhost:8000',
infoPanelWidth: 400,
theme: 'light'
};
export const useSettingsStore = create<SettingsState>()(
persist(
immer((set) => ({
...DEFAULT_SETTINGS,
updateSettings: (settings: Partial<Settings>) =>
set((state) => {
Object.assign(state, settings);
}),
resetSettings: () =>
set((state) => {
Object.assign(state, DEFAULT_SETTINGS);
})
})),
{
name: 'debug-tool-settings'
}
)
);

View File

@ -0,0 +1,40 @@
import { css } from '@emotion/react';
export const globalStyles = css`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #212529;
background-color: #fff;
}
#root {
height: 100%;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
`;

View File

@ -0,0 +1,44 @@
<script>
4
5
4
54
5
45
4
5
45
4
2
42
45
5
75
5
alert('test')
</script>

View File

@ -0,0 +1,187 @@
export type ImageSource = {
type: 'memory';
value: string[];
};
/**
*
*/
export type VisualEventData = {
/** 图片数据源 */
image: ImageSource;
/** 函数或操作的名称 */
name: string;
/** 详细的文本信息 */
details: string;
};
/**
*
*/
export type VisualEvent = {
type: 'visual';
data: VisualEventData;
};
/**
* WebSocket
*/
export type ConnectionStatusEvent = {
type: 'connectionStatus';
/** 连接状态true 表示已连接false 表示已断开 */
connected: boolean;
};
/** 支持的事件类型 */
export type KotoneDebugEvents = 'visual' | 'connectionStatus';
/**
*
*/
type EventTypeMap<T extends KotoneDebugEvents> = T extends 'visual'
? VisualEvent
: T extends 'connectionStatus'
? ConnectionStatusEvent
: never;
/**
*
*/
type EventListenerMap = {
visual: Array<(data: VisualEvent) => void>;
connectionStatus: Array<(data: ConnectionStatusEvent) => void>;
};
/**
* Kotone
* WebSocket
*/
export class KotoneDebugClient {
/** WebSocket 实例 */
#ws: WebSocket | null = null;
/** 事件监听器映射 */
#eventListeners: EventListenerMap = {
visual: [],
connectionStatus: []
};
/** 是否正在重连 */
#isReconnecting: boolean;
/** WebSocket 服务器 URL */
#serverUrl: string;
#host: string;
/**
* Kotone
* @param host - WebSocket IP
*/
constructor(host: string) {
this.#host = host;
this.#serverUrl = `ws://${host}/ws`;
this.#isReconnecting = false;
this.#connect();
}
/**
*
* @param event -
* @param callback -
*/
addEventListener<T extends KotoneDebugEvents>(
event: T,
callback: (e: EventTypeMap<T>) => void
): void {
(this.#eventListeners[event] as Array<(data: EventTypeMap<T>) => void>).push(callback);
}
/**
*
* @param event -
* @param callback -
*/
removeEventListener<T extends KotoneDebugEvents>(
event: T,
callback: (e: EventTypeMap<T>) => void
): void {
const listeners = this.#eventListeners[event] as Array<(data: EventTypeMap<T>) => void>;
const index = listeners.indexOf(callback);
if (index !== -1) {
listeners.splice(index, 1);
}
}
/**
* WebSocket
* @private
*/
#connect(): void {
if (this.#isReconnecting) return;
this.#isReconnecting = true;
try {
this.#ws = new WebSocket(this.#serverUrl);
} catch (error) {
console.log(error);
this.#checkServerStatus();
return;
}
this.#ws.onopen = () => {
console.log('WebSocket connected');
this.#isReconnecting = false;
this.#dispatchEvent('connectionStatus', { type: 'connectionStatus', connected: true });
};
this.#ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.error) {
console.error('WebSocket error:', data.error);
return;
}
if (data.type === 'visual') {
console.log('WebSocket message(visual):', data);
this.#dispatchEvent('visual', data);
}
};
this.#ws.onclose = () => {
console.log('WebSocket disconnected');
this.#dispatchEvent('connectionStatus', { type: 'connectionStatus', connected: false });
this.#checkServerStatus();
};
this.#ws.onerror = (error: Event) => {
console.error('WebSocket error:', error);
this.#dispatchEvent('connectionStatus', { type: 'connectionStatus', connected: false });
this.#checkServerStatus();
};
}
/**
*
* @private
*/
async #checkServerStatus(): Promise<void> {
try {
const response = await fetch(`http://${this.#host}/api/ping`);
if (response.ok) {
this.#connect();
} else {
setTimeout(() => this.#checkServerStatus(), 2000);
}
} catch {
setTimeout(() => this.#checkServerStatus(), 2000);
}
}
/**
*
* @private
* @param event -
* @param data -
*/
#dispatchEvent<T extends KotoneDebugEvents>(event: T, data: EventTypeMap<T>): void {
const listeners = this.#eventListeners[event] as Array<(data: EventTypeMap<T>) => void>;
listeners.forEach(callback => callback(data));
}
}

View File

@ -0,0 +1,40 @@
import { DebugRecord } from '../types/debug';
export const readJsonFile = async (file: File): Promise<DebugRecord[]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target?.result as string;
const data = JSON.parse(content);
if (Array.isArray(data) && data.every(item =>
item.id && item.timestamp && item.message)) {
resolve(data as DebugRecord[]);
} else {
reject(new Error('文件格式不正确'));
}
} catch {
reject(new Error('无法解析JSON文件'));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
});
};
export const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
};
export const generateUniqueId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
};

View File

@ -0,0 +1,82 @@
import { ViewState } from '../types/debug';
export const DEFAULT_VIEW_STATE: ViewState = {
scale: 1,
x: 0,
y: 0,
locked: false,
};
export const calculateImageDimensions = (
imageWidth: number,
imageHeight: number,
containerWidth: number,
containerHeight: number
): { width: number; height: number; scale: number } => {
const imageRatio = imageWidth / imageHeight;
const containerRatio = containerWidth / containerHeight;
let scale = 1;
let width = imageWidth;
let height = imageHeight;
if (imageRatio > containerRatio) {
// 图片更宽,以容器宽度为基准
if (imageWidth > containerWidth) {
scale = containerWidth / imageWidth;
width = containerWidth;
height = imageHeight * scale;
}
} else {
// 图片更高,以容器高度为基准
if (imageHeight > containerHeight) {
scale = containerHeight / imageHeight;
height = containerHeight;
width = imageWidth * scale;
}
}
return { width, height, scale };
};
export const calculateZoom = (
currentScale: number,
delta: number,
minScale = 0.1,
maxScale = 5
): number => {
const ZOOM_SENSITIVITY = 0.001;
const newScale = currentScale * (1 - delta * ZOOM_SENSITIVITY);
return Math.min(Math.max(newScale, minScale), maxScale);
};
export const getImageUrl = (
type: 'memory' | 'file',
path: string,
baseUrl = ''
): string => {
if (type === 'memory') {
return `${baseUrl}/api/read_memory?key=${encodeURIComponent(path)}`;
}
return `${baseUrl}/api/read_file?path=${encodeURIComponent(path)}`;
};
export const downloadImage = async (url: string, filename: string): Promise<void> => {
try {
const response = await fetch(url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.error('下载图片失败:', error);
throw error;
}
};

1
kotonebot-devtool/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const webComponentImport = {
name: 'web-component-import',
transform(code: string, id: string) {
if (id.endsWith('.component.html')) {
const retCodes: string[] = [];
retCodes.push('loadComponent(');
code.split('\n').forEach(line => {
line = line.trimEnd();
line = line.replaceAll('"', '\\"');
line = '"' + line + '\\n" +';
retCodes.push(line);
});
retCodes.push('""')
retCodes.push(');');
// console.log(retCodes.join('\n'));
return retCodes.join('\n');
} else {
return code;
}
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({ jsxImportSource: '@emotion/react' }),
webComponentImport
],
})

View File

@ -68,7 +68,13 @@ def goto_shop():
until(at_daily_shop, critical=True) until(at_daily_shop, critical=True)
@action('测试颜色')
def test():
from kotonebot import color
while True:
print(color.find_rgb('#ffffff', threshold=0.9999))
print(image.find(R.Common.ButtonHome))
if __name__ == "__main__": if __name__ == "__main__":
print(at_home()) import time
print(at_daily_shop()) test()
goto_shop()