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)
|
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()
|
|
||||||
|
|
Loading…
Reference in New Issue