refactor(devtool): 使用 React 完全重构可视调试工具
This commit is contained in:
parent
c6d80a2215
commit
87427950fc
|
@ -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?
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
```
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { MainLayout } from './DumpViewer/MainLayout';
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 />,
|
||||
},
|
||||
]);
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,44 @@
|
|||
<script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
|
||||
5
|
||||
4
|
||||
54
|
||||
5
|
||||
45
|
||||
4
|
||||
5
|
||||
45
|
||||
4
|
||||
|
||||
2
|
||||
42
|
||||
45
|
||||
|
||||
5
|
||||
75
|
||||
|
||||
|
||||
5
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
alert('test')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -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"]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
|
@ -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"]
|
||||
}
|
|
@ -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
|
||||
],
|
||||
|
||||
})
|
|
@ -68,7 +68,13 @@ def goto_shop():
|
|||
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__":
|
||||
print(at_home())
|
||||
print(at_daily_shop())
|
||||
goto_shop()
|
||||
import time
|
||||
test()
|
||||
|
|
Loading…
Reference in New Issue