@forge-kit/plugin-qr-code 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/dist/app.js +72 -0
- package/dist/icon.svg +7 -0
- package/package.json +52 -0
- package/src/App.less +34 -0
- package/src/App.tsx +33 -0
- package/src/components/qr-decoder-panel.less +58 -0
- package/src/components/qr-decoder-panel.tsx +105 -0
- package/src/components/qr-generator-panel.less +57 -0
- package/src/components/qr-generator-panel.tsx +154 -0
- package/src/components/upload-file-view.less +26 -0
- package/src/components/upload-file-view.tsx +80 -0
- package/src/hooks/use-debounced-callback.ts +14 -0
- package/src/hooks/use-latest-task-guard.ts +21 -0
- package/src/hooks/use-partial-state.ts +15 -0
- package/src/hooks/use-to-ref.ts +11 -0
- package/src/main.tsx +30 -0
- package/src/utils/qr-code.ts +85 -0
package/dist/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg width="192" height="192" viewBox="0 0 192 192" fill="" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M85 0H8C3.575 0 0 3.575 0 8V85C0 86.1 0.9 87 2 87H85C86.1 87 87 86.1 87 85V2C87 0.9 86.1 0 85 0ZM71 71H16V16H71V71Z" />
|
|
3
|
+
<path d="M36.5 52.5H50.5C51.6 52.5 52.5 51.6 52.5 50.5V36.5C52.5 35.4 51.6 34.5 50.5 34.5H36.5C35.4 34.5 34.5 35.4 34.5 36.5V50.5C34.5 51.6 35.4 52.5 36.5 52.5ZM85 105H2C0.9 105 0 105.9 0 107V184C0 188.425 3.575 192 8 192H85C86.1 192 87 191.1 87 190V107C87 105.9 86.1 105 85 105ZM71 176H16V121H71V176Z" />
|
|
4
|
+
<path d="M36.5 157.5H50.5C51.6 157.5 52.5 156.6 52.5 155.5V141.5C52.5 140.4 51.6 139.5 50.5 139.5H36.5C35.4 139.5 34.5 140.4 34.5 141.5V155.5C34.5 156.6 35.4 157.5 36.5 157.5ZM184 0H107C105.9 0 105 0.9 105 2V85C105 86.1 105.9 87 107 87H190C191.1 87 192 86.1 192 85V8C192 3.575 188.425 0 184 0ZM176 71H121V16H176V71Z" />
|
|
5
|
+
<path d="M141.5 52.5H155.5C156.6 52.5 157.5 51.6 157.5 50.5V36.5C157.5 35.4 156.6 34.5 155.5 34.5H141.5C140.4 34.5 139.5 35.4 139.5 36.5V50.5C139.5 51.6 140.4 52.5 141.5 52.5ZM190 105H178C176.9 105 176 105.9 176 107V140.5H156.5V107C156.5 105.9 155.6 105 154.5 105H107C105.9 105 105 105.9 105 107V190C105 191.1 105.9 192 107 192H119C120.1 192 121 191.1 121 190V129H140.5V154.5C140.5 155.6 141.4 156.5 142.5 156.5H190C191.1 156.5 192 155.6 192 154.5V107C192 105.9 191.1 105 190 105Z" />
|
|
6
|
+
<path d="M154.5 176H142.5C141.4 176 140.5 176.9 140.5 178V190C140.5 191.1 141.4 192 142.5 192H154.5C155.6 192 156.5 191.1 156.5 190V178C156.5 176.9 155.6 176 154.5 176ZM190 176H178C176.9 176 176 176.9 176 178V190C176 191.1 176.9 192 178 192H190C191.1 192 192 191.1 192 190V178C192 176.9 191.1 176 190 176Z" />
|
|
7
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forge-kit/plugin-qr-code",
|
|
3
|
+
"entry": "./dist/app.js",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"author": "yu.pan <panyupy@vip.qq.com>",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"version": "0.0.1",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "vite",
|
|
17
|
+
"build": "tsc -b && vite build",
|
|
18
|
+
"lint": "eslint .",
|
|
19
|
+
"preview": "vite preview"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@forge-kit/component": "workspace:*",
|
|
23
|
+
"@forge-kit/icons": "workspace:*",
|
|
24
|
+
"@forge-kit/types": "workspace:*",
|
|
25
|
+
"classnames": "^2.5.1",
|
|
26
|
+
"jsqr": "^1.4.0",
|
|
27
|
+
"lodash-es": "^4.17.23",
|
|
28
|
+
"qrcode": "^1.5.4",
|
|
29
|
+
"react": "^19.2.0",
|
|
30
|
+
"react-dom": "^19.2.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@eslint/js": "^9.39.1",
|
|
34
|
+
"@forge-kit/vite-plugin-forge-kit": "workspace:*",
|
|
35
|
+
"@types/lodash-es": "^4.17.12",
|
|
36
|
+
"@types/node": "^24.10.1",
|
|
37
|
+
"@types/qrcode": "^1.5.6",
|
|
38
|
+
"@types/react": "^19.2.5",
|
|
39
|
+
"@types/react-dom": "^19.2.3",
|
|
40
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
41
|
+
"eslint": "^9.39.1",
|
|
42
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
43
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
44
|
+
"globals": "^16.5.0",
|
|
45
|
+
"typescript": "~5.9.3",
|
|
46
|
+
"typescript-eslint": "^8.46.4",
|
|
47
|
+
"vite": "npm:rolldown-vite@7.2.5"
|
|
48
|
+
},
|
|
49
|
+
"overrides": {
|
|
50
|
+
"vite": "npm:rolldown-vite@7.2.5"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/App.less
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body,
|
|
3
|
+
#root {
|
|
4
|
+
-webkit-app-region: drag;
|
|
5
|
+
background-color: transparent;
|
|
6
|
+
margin: 0;
|
|
7
|
+
height: 100%;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.forge-kit-plugin-qr-code {
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
|
|
16
|
+
.qr-tabs {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
width: 100%;
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
height: 100%;
|
|
22
|
+
|
|
23
|
+
.tab-list {
|
|
24
|
+
flex-shrink: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.tab-panel {
|
|
28
|
+
flex: 1;
|
|
29
|
+
margin-top: var(--space-3);
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
height: max-content;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {Theme, Tabs} from '@forge-kit/component'
|
|
3
|
+
import '@forge-kit/component/style.css'
|
|
4
|
+
import {QrDecoderPanel} from '@/components/qr-decoder-panel'
|
|
5
|
+
import {QrGeneratorPanel} from '@/components/qr-generator-panel'
|
|
6
|
+
import './App.less'
|
|
7
|
+
|
|
8
|
+
interface AppProps {
|
|
9
|
+
themeProps?: React.ComponentProps<typeof Theme>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const App: React.FC<AppProps> = (props) => {
|
|
13
|
+
const {themeProps} = props
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Theme className="forge-kit-plugin-qr-code" accentColor="gray" appearance="dark" radius="full" grayColor="slate" panelBackground="translucent" {...themeProps}>
|
|
17
|
+
<Tabs.Root defaultValue="generate" className="qr-tabs">
|
|
18
|
+
<Tabs.List size="2" className="tab-list">
|
|
19
|
+
<Tabs.Trigger value="generate">生成二维码</Tabs.Trigger>
|
|
20
|
+
<Tabs.Trigger value="decode">解析二维码</Tabs.Trigger>
|
|
21
|
+
</Tabs.List>
|
|
22
|
+
|
|
23
|
+
<Tabs.Content value="generate" className="tab-panel">
|
|
24
|
+
<QrGeneratorPanel/>
|
|
25
|
+
</Tabs.Content>
|
|
26
|
+
|
|
27
|
+
<Tabs.Content value="decode" className="tab-panel">
|
|
28
|
+
<QrDecoderPanel/>
|
|
29
|
+
</Tabs.Content>
|
|
30
|
+
</Tabs.Root>
|
|
31
|
+
</Theme>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
.qr-decoder-panel {
|
|
2
|
+
&-grid {
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
&-card {
|
|
8
|
+
height: 100%;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
padding: var(--space-4);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
&-control,
|
|
14
|
+
&-preview {
|
|
15
|
+
flex: 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
&-preview {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&-preview-content {
|
|
25
|
+
width: 100%;
|
|
26
|
+
height: 100%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&-preview-stage {
|
|
30
|
+
flex: 1;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
border: 1px dashed var(--gray-7);
|
|
37
|
+
border-radius: var(--radius-3);
|
|
38
|
+
padding: var(--space-2);
|
|
39
|
+
background-color: var(--gray-2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&-result {
|
|
43
|
+
border: 1px solid var(--gray-6);
|
|
44
|
+
border-radius: var(--radius-3);
|
|
45
|
+
padding: var(--space-3);
|
|
46
|
+
background-color: var(--gray-2);
|
|
47
|
+
overflow: auto;
|
|
48
|
+
white-space: pre-wrap;
|
|
49
|
+
word-break: break-all;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&-preview-image {
|
|
53
|
+
max-width: 100%;
|
|
54
|
+
max-height: 260px;
|
|
55
|
+
object-fit: contain;
|
|
56
|
+
border-radius: var(--radius-2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, {useEffect} from 'react'
|
|
2
|
+
import {Box, Button, Card, Flex, Text} from '@forge-kit/component'
|
|
3
|
+
import classNames from "classnames";
|
|
4
|
+
import {UploadFileView} from '@/components/upload-file-view'
|
|
5
|
+
import {decodeQrFromImageUrl} from '@/utils/qr-code'
|
|
6
|
+
import {usePartialState} from '@/hooks/use-partial-state'
|
|
7
|
+
import {useToRef} from '@/hooks/use-to-ref'
|
|
8
|
+
import './qr-decoder-panel.less'
|
|
9
|
+
|
|
10
|
+
interface DecoderState {
|
|
11
|
+
decodeText: string
|
|
12
|
+
decodeError: string
|
|
13
|
+
decodeImageName: string
|
|
14
|
+
decodeImageUrl: string
|
|
15
|
+
isDecoding: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_DECODER_STATE: DecoderState = {
|
|
19
|
+
decodeText: '',
|
|
20
|
+
decodeError: '',
|
|
21
|
+
decodeImageName: '',
|
|
22
|
+
decodeImageUrl: '',
|
|
23
|
+
isDecoding: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const QrDecoderPanel: React.FC = () => {
|
|
27
|
+
const {state: decoderState, updateState: updateDecoderState} = usePartialState<DecoderState>(DEFAULT_DECODER_STATE)
|
|
28
|
+
|
|
29
|
+
const revokeDecodeImageUrl = useToRef(() => {
|
|
30
|
+
if (!decoderState.decodeImageUrl) return
|
|
31
|
+
URL.revokeObjectURL(decoderState.decodeImageUrl)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const revokeDecodeImageUrlFn = revokeDecodeImageUrl.current
|
|
36
|
+
return () => revokeDecodeImageUrlFn()
|
|
37
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
+
}, [decoderState.decodeImageUrl])
|
|
39
|
+
|
|
40
|
+
const handleDecodeFile = async (file: File) => {
|
|
41
|
+
const imageUrl = URL.createObjectURL(file)
|
|
42
|
+
revokeDecodeImageUrl.current()
|
|
43
|
+
|
|
44
|
+
updateDecoderState({
|
|
45
|
+
decodeImageName: file.name,
|
|
46
|
+
decodeImageUrl: imageUrl,
|
|
47
|
+
decodeError: '',
|
|
48
|
+
decodeText: '',
|
|
49
|
+
isDecoding: true,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = await decodeQrFromImageUrl(imageUrl)
|
|
54
|
+
updateDecoderState({decodeText: result})
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const errorMessage = error instanceof Error ? error.message : '二维码识别失败,请重试'
|
|
57
|
+
updateDecoderState({decodeError: errorMessage})
|
|
58
|
+
} finally {
|
|
59
|
+
updateDecoderState({isDecoding: false})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handleInvalidFile = () => {
|
|
64
|
+
updateDecoderState({decodeError: '仅支持上传图片文件'})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleCopyDecodedText = async () => {
|
|
68
|
+
if (!decoderState.decodeText) return
|
|
69
|
+
try {
|
|
70
|
+
await navigator.clipboard.writeText(decoderState.decodeText)
|
|
71
|
+
} catch {
|
|
72
|
+
updateDecoderState({decodeError: '复制失败,请手动复制识别结果'})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Flex className="qr-decoder-panel-grid" gap="4">
|
|
78
|
+
<Card className={classNames('qr-decoder-panel-card', 'qr-decoder-panel-control')}>
|
|
79
|
+
<Flex direction="column" gap="3">
|
|
80
|
+
<Text size="2" weight="medium">上传二维码图片</Text>
|
|
81
|
+
<UploadFileView onFileSelected={handleDecodeFile} onInvalidFile={handleInvalidFile}/>
|
|
82
|
+
{decoderState.isDecoding && <Text size="1" color="gray">识别中...</Text>}
|
|
83
|
+
{Boolean(decoderState.decodeError) && <Text size="1" color="red">{decoderState.decodeError}</Text>}
|
|
84
|
+
{Boolean(decoderState.decodeImageName) && <Text size="1" color="gray">当前文件: {decoderState.decodeImageName}</Text>}
|
|
85
|
+
</Flex>
|
|
86
|
+
</Card>
|
|
87
|
+
|
|
88
|
+
<Card className={classNames('qr-decoder-panel-card', 'qr-decoder-panel-preview')}>
|
|
89
|
+
<Flex direction="column" gap="3" className="qr-decoder-panel-preview-content">
|
|
90
|
+
<Box className="qr-decoder-panel-preview-stage">
|
|
91
|
+
{Boolean(decoderState.decodeImageUrl) && (<img className="qr-decoder-panel-preview-image" src={decoderState.decodeImageUrl} alt="上传二维码预览"/>)}
|
|
92
|
+
{!decoderState.decodeImageUrl && <Text color="gray">上传图片后在此预览</Text>}
|
|
93
|
+
</Box>
|
|
94
|
+
|
|
95
|
+
<Box className="qr-decoder-panel-result">
|
|
96
|
+
{Boolean(decoderState.decodeText) && <Text>{decoderState.decodeText}</Text>}
|
|
97
|
+
{!decoderState.decodeText && <Text color="gray">识别结果会显示在这里</Text>}
|
|
98
|
+
</Box>
|
|
99
|
+
|
|
100
|
+
<Button variant="soft" disabled={!decoderState.decodeText} onClick={handleCopyDecodedText}>复制结果</Button>
|
|
101
|
+
</Flex>
|
|
102
|
+
</Card>
|
|
103
|
+
</Flex>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.qr-generator-panel {
|
|
2
|
+
width: 100%;
|
|
3
|
+
height: 100%;
|
|
4
|
+
|
|
5
|
+
&-card {
|
|
6
|
+
height: 100%;
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
padding: var(--space-4);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
&-control,
|
|
12
|
+
&-preview {
|
|
13
|
+
flex: 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&-preview {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&-preview-content {
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&-preview-stage {
|
|
28
|
+
flex: 1;
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&-textarea {
|
|
36
|
+
margin-top: var(--space-2);
|
|
37
|
+
min-height: 140px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&-field-label {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
gap: 6px;
|
|
44
|
+
min-width: 120px;
|
|
45
|
+
flex: 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&-preview-image {
|
|
49
|
+
max-width: 100%;
|
|
50
|
+
max-height: 100%;
|
|
51
|
+
object-fit: contain;
|
|
52
|
+
border-radius: var(--radius-2);
|
|
53
|
+
border: 1px solid var(--gray-6);
|
|
54
|
+
padding: var(--space-2);
|
|
55
|
+
background: #fff;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, {useEffect} from 'react'
|
|
2
|
+
import {Box, Button, Card, Flex, Text, TextArea, Select, TextField} from '@forge-kit/component'
|
|
3
|
+
import {
|
|
4
|
+
downloadDataUrl,
|
|
5
|
+
generateQrDataUrl,
|
|
6
|
+
QR_ERROR_CORRECTION_LEVEL_OPTIONS,
|
|
7
|
+
} from '@/utils/qr-code'
|
|
8
|
+
import classNames from "classnames";
|
|
9
|
+
import type {QRCodeErrorCorrectionLevel} from 'qrcode'
|
|
10
|
+
import {useDebouncedCallback} from '@/hooks/use-debounced-callback'
|
|
11
|
+
import {useLatestTaskGuard} from '@/hooks/use-latest-task-guard'
|
|
12
|
+
import {usePartialState} from '@/hooks/use-partial-state'
|
|
13
|
+
import {useToRef} from '@/hooks/use-to-ref'
|
|
14
|
+
import './qr-generator-panel.less'
|
|
15
|
+
|
|
16
|
+
interface GeneratorConfigState {
|
|
17
|
+
text: string
|
|
18
|
+
size: number
|
|
19
|
+
margin: number
|
|
20
|
+
errorCorrectionLevel: QRCodeErrorCorrectionLevel
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GeneratorResultState {
|
|
24
|
+
qrDataUrl: string
|
|
25
|
+
generateError: string
|
|
26
|
+
isGenerating: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_GENERATOR_CONFIG: GeneratorConfigState = {
|
|
30
|
+
text: '',
|
|
31
|
+
size: 320,
|
|
32
|
+
margin: 2,
|
|
33
|
+
errorCorrectionLevel: 'M',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_GENERATOR_RESULT: GeneratorResultState = {
|
|
37
|
+
qrDataUrl: '',
|
|
38
|
+
generateError: '',
|
|
39
|
+
isGenerating: false,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const QrGeneratorPanel: React.FC = () => {
|
|
43
|
+
const {state: configState, updateState: updateConfigState} = usePartialState<GeneratorConfigState>(DEFAULT_GENERATOR_CONFIG)
|
|
44
|
+
const {state: resultState, updateState: updateResultState} = usePartialState<GeneratorResultState>(DEFAULT_GENERATOR_RESULT)
|
|
45
|
+
|
|
46
|
+
const generateLatestTaskGuardQrDataUrl = useLatestTaskGuard(async (nextConfig: GeneratorConfigState) => {
|
|
47
|
+
const {text, size, margin, errorCorrectionLevel} = nextConfig
|
|
48
|
+
return generateQrDataUrl(text.trim(), {width: size, margin, errorCorrectionLevel})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const runGenerateByConfig = useToRef(async (nextConfig: GeneratorConfigState) => {
|
|
52
|
+
const normalizedText = nextConfig.text.trim()
|
|
53
|
+
|
|
54
|
+
if (!normalizedText) {
|
|
55
|
+
updateResultState({generateError: '请输入二维码内容', qrDataUrl: '', isGenerating: false})
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updateResultState({isGenerating: true, generateError: ''})
|
|
60
|
+
try {
|
|
61
|
+
const dataUrl = await generateLatestTaskGuardQrDataUrl(nextConfig)
|
|
62
|
+
if (dataUrl === undefined) return
|
|
63
|
+
updateResultState({qrDataUrl: dataUrl, isGenerating: false})
|
|
64
|
+
} catch {
|
|
65
|
+
updateResultState({generateError: '二维码生成失败,请检查输入后重试', qrDataUrl: '', isGenerating: false})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const debouncedGenerate = useDebouncedCallback((nextConfig: GeneratorConfigState) => {
|
|
70
|
+
return runGenerateByConfig.current(nextConfig)
|
|
71
|
+
}, 300)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
debouncedGenerate(configState)
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [configState])
|
|
77
|
+
|
|
78
|
+
const handleDownload = () => {
|
|
79
|
+
if (!resultState.qrDataUrl) return
|
|
80
|
+
downloadDataUrl(resultState.qrDataUrl, 'qrcode.png')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Flex className="qr-generator-panel" gap="4">
|
|
85
|
+
<Card className={classNames('qr-generator-panel-card', 'qr-generator-panel-control')}>
|
|
86
|
+
<Flex direction="column" gap="3">
|
|
87
|
+
<Box>
|
|
88
|
+
<Text size="1" color="gray">内容</Text>
|
|
89
|
+
<TextArea
|
|
90
|
+
className="qr-generator-panel-textarea"
|
|
91
|
+
size="2"
|
|
92
|
+
value={configState.text}
|
|
93
|
+
placeholder="输入文本、链接或任意内容"
|
|
94
|
+
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => updateConfigState({text: event.target.value})}
|
|
95
|
+
/>
|
|
96
|
+
</Box>
|
|
97
|
+
|
|
98
|
+
<Flex gap="3" wrap="wrap">
|
|
99
|
+
<Flex className="qr-generator-panel-field-label">
|
|
100
|
+
<Text size="1" color="gray">尺寸(px)</Text>
|
|
101
|
+
<TextField.Root
|
|
102
|
+
type="number"
|
|
103
|
+
radius="large"
|
|
104
|
+
min={120}
|
|
105
|
+
max={1024}
|
|
106
|
+
value={configState.size}
|
|
107
|
+
onChange={(event) => updateConfigState({size: Number(event.target.value) || DEFAULT_GENERATOR_CONFIG.size})}
|
|
108
|
+
/>
|
|
109
|
+
</Flex>
|
|
110
|
+
|
|
111
|
+
<Flex className="qr-generator-panel-field-label">
|
|
112
|
+
<Text size="1" color="gray">边距</Text>
|
|
113
|
+
<TextField.Root
|
|
114
|
+
type="number"
|
|
115
|
+
radius="large"
|
|
116
|
+
min={0}
|
|
117
|
+
max={12}
|
|
118
|
+
value={configState.margin}
|
|
119
|
+
onChange={(event) => updateConfigState({margin: Number(event.target.value) || 0})}
|
|
120
|
+
/>
|
|
121
|
+
</Flex>
|
|
122
|
+
|
|
123
|
+
<Flex className="qr-generator-panel-field-label">
|
|
124
|
+
<Text size="1" color="gray">容错级别</Text>
|
|
125
|
+
<Select.Root
|
|
126
|
+
value={configState.errorCorrectionLevel}
|
|
127
|
+
onValueChange={(value: QRCodeErrorCorrectionLevel) => updateConfigState({errorCorrectionLevel: value})}
|
|
128
|
+
>
|
|
129
|
+
<Select.Trigger radius="large"/>
|
|
130
|
+
<Select.Content>
|
|
131
|
+
{QR_ERROR_CORRECTION_LEVEL_OPTIONS.map((level) => (
|
|
132
|
+
<Select.Item key={level} value={level}>{level}</Select.Item>
|
|
133
|
+
))}
|
|
134
|
+
</Select.Content>
|
|
135
|
+
</Select.Root>
|
|
136
|
+
</Flex>
|
|
137
|
+
</Flex>
|
|
138
|
+
{resultState.isGenerating && <Text size="1" color="gray">生成中...</Text>}
|
|
139
|
+
{Boolean(resultState.generateError) && <Text size="1" color="red">{resultState.generateError}</Text>}
|
|
140
|
+
</Flex>
|
|
141
|
+
</Card>
|
|
142
|
+
|
|
143
|
+
<Card className={classNames('qr-generator-panel-card', 'qr-generator-panel-preview')}>
|
|
144
|
+
<Flex direction="column" gap="3" className="qr-generator-panel-preview-content">
|
|
145
|
+
<Box className="qr-generator-panel-preview-stage">
|
|
146
|
+
{Boolean(resultState.qrDataUrl) && (<img className="qr-generator-panel-preview-image" src={resultState.qrDataUrl} alt="二维码预览"/>)}
|
|
147
|
+
{!resultState.qrDataUrl && <Text color="gray">输入内容后将自动生成二维码</Text>}
|
|
148
|
+
</Box>
|
|
149
|
+
<Button variant="soft" onClick={handleDownload} disabled={!resultState.qrDataUrl}>下载 PNG</Button>
|
|
150
|
+
</Flex>
|
|
151
|
+
</Card>
|
|
152
|
+
</Flex>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.upload-file-view {
|
|
2
|
+
&-input {
|
|
3
|
+
display: none;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
&-dropzone {
|
|
7
|
+
min-height: 180px;
|
|
8
|
+
border: 1px dashed var(--gray-7);
|
|
9
|
+
border-radius: var(--radius-3);
|
|
10
|
+
background-color: var(--gray-2);
|
|
11
|
+
padding: var(--space-3);
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
transition: border-color 0.2s ease, background-color 0.2s ease;
|
|
17
|
+
outline: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&-dropzone:hover,
|
|
21
|
+
&-dropzone:focus-visible,
|
|
22
|
+
&-dropzone.is-drag-over {
|
|
23
|
+
border-color: var(--gray-10);
|
|
24
|
+
background-color: var(--gray-3);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, {useRef, useState} from 'react'
|
|
2
|
+
import {Box, Flex, Text} from '@forge-kit/component'
|
|
3
|
+
import {getImageFileFromClipboard, getImageFileFromFileList} from '@/utils/qr-code'
|
|
4
|
+
import classNames from 'classnames'
|
|
5
|
+
import './upload-file-view.less'
|
|
6
|
+
|
|
7
|
+
interface UploadFileViewProps {
|
|
8
|
+
helperText?: string
|
|
9
|
+
onFileSelected: (file: File) => Promise<void> | void
|
|
10
|
+
onInvalidFile?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const UploadFileView: React.FC<UploadFileViewProps> = (props) => {
|
|
14
|
+
const {helperText = '支持拖动、Ctrl/Cmd+V 粘贴、点击上传', onFileSelected, onInvalidFile} = props
|
|
15
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
16
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
17
|
+
|
|
18
|
+
const handleInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
19
|
+
const file = getImageFileFromFileList(event.target.files)
|
|
20
|
+
if (!file) return
|
|
21
|
+
await onFileSelected(file)
|
|
22
|
+
event.target.value = ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
setIsDragOver(false)
|
|
28
|
+
|
|
29
|
+
const file = getImageFileFromFileList(event.dataTransfer.files)
|
|
30
|
+
if (!file) {
|
|
31
|
+
if (event.dataTransfer.files.length > 0) onInvalidFile?.()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
await onFileSelected(file)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handlePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
38
|
+
const file = getImageFileFromClipboard(event.clipboardData)
|
|
39
|
+
if (!file) return
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
await onFileSelected(file)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handleChooseFileClick = () => {
|
|
45
|
+
fileInputRef.current?.click()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
setIsDragOver(true)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handleDragLeave = () => {
|
|
54
|
+
setIsDragOver(false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Flex className="upload-file-view" direction="column" gap="2">
|
|
59
|
+
<Text size="1" color="gray">{helperText}</Text>
|
|
60
|
+
<input
|
|
61
|
+
ref={fileInputRef}
|
|
62
|
+
className="upload-file-view-input"
|
|
63
|
+
type="file"
|
|
64
|
+
accept="image/*"
|
|
65
|
+
onChange={handleInputChange}
|
|
66
|
+
/>
|
|
67
|
+
<Box
|
|
68
|
+
className={classNames('upload-file-view-dropzone', {'is-drag-over': isDragOver})}
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
onClick={handleChooseFileClick}
|
|
71
|
+
onPaste={handlePaste}
|
|
72
|
+
onDragOver={handleDragOver}
|
|
73
|
+
onDragLeave={handleDragLeave}
|
|
74
|
+
onDrop={handleDrop}
|
|
75
|
+
>
|
|
76
|
+
<Text align="center" color="gray">拖拽 / 点击 / 粘贴 上传</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
</Flex>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import debounce from 'lodash-es/debounce'
|
|
2
|
+
import {useEffect, useMemo} from 'react'
|
|
3
|
+
|
|
4
|
+
export const useDebouncedCallback = <Args extends unknown[]>(callback: (...args: Args) => void, wait: number) => {
|
|
5
|
+
const debouncedCallback = useMemo(() => {
|
|
6
|
+
return debounce(callback, wait)
|
|
7
|
+
}, [callback, wait])
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
return () => debouncedCallback.cancel()
|
|
11
|
+
}, [debouncedCallback])
|
|
12
|
+
|
|
13
|
+
return debouncedCallback
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {useCallback, useRef} from 'react'
|
|
2
|
+
|
|
3
|
+
export const useLatestTaskGuard = <Args extends unknown[], Result>(
|
|
4
|
+
execute: (...args: Args) => Promise<Result>,
|
|
5
|
+
) => {
|
|
6
|
+
const latestTaskIdRef = useRef(0)
|
|
7
|
+
|
|
8
|
+
return useCallback(async (...args: Args): Promise<Result | undefined> => {
|
|
9
|
+
latestTaskIdRef.current += 1
|
|
10
|
+
const currentTaskId = latestTaskIdRef.current
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = await execute(...args)
|
|
14
|
+
if (currentTaskId !== latestTaskIdRef.current) return
|
|
15
|
+
return result
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (currentTaskId !== latestTaskIdRef.current) return
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
}, [execute])
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react'
|
|
2
|
+
|
|
3
|
+
export const usePartialState = <T extends object>(initialState: T) => {
|
|
4
|
+
const [state, setState] = useState<T>(initialState)
|
|
5
|
+
|
|
6
|
+
const updateState = useCallback((patch: Partial<T>) => {
|
|
7
|
+
setState((prev) => ({...prev, ...patch}))
|
|
8
|
+
}, [])
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
state,
|
|
12
|
+
setState,
|
|
13
|
+
updateState,
|
|
14
|
+
}
|
|
15
|
+
}
|