@huaqiu/component-gen-app 0.3.6
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/LICENSE +21 -0
- package/dist/assets/index-DTShI_jq.js +553 -0
- package/dist/index.html +12 -0
- package/lib/index.d.ts +427 -0
- package/lib/index.js +2550 -0
- package/package.json +48 -0
- package/src/App.tsx +101 -0
- package/src/api/component-gen-client.ts +225 -0
- package/src/components/GeometryEditor.tsx +418 -0
- package/src/components/HistoryPanel.tsx +119 -0
- package/src/components/PreviewStage.tsx +67 -0
- package/src/components/ResultStage.tsx +92 -0
- package/src/components/UploadInput.tsx +121 -0
- package/src/copy/en.ts +150 -0
- package/src/copy/index.ts +50 -0
- package/src/copy/zh.ts +154 -0
- package/src/hooks/useAuthGate.ts +51 -0
- package/src/hooks/useJobRunner.ts +127 -0
- package/src/index.ts +37 -0
- package/src/main.tsx +85 -0
- package/src/pages/FootprintGenPage.tsx +185 -0
- package/src/pages/SymbolGenPage.tsx +136 -0
- package/src/ports.ts +149 -0
- package/src/styles/inject.ts +124 -0
- package/src/utils/dims.ts +266 -0
- package/src/utils/ecad.ts +91 -0
- package/src/utils/labels.ts +76 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — reusable generation job runner.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the start → SSE-stream → terminal-state lifecycle for one
|
|
5
|
+
* page's active generation. The page calls `run(req)` and reacts to `phase`.
|
|
6
|
+
* `ports.jobEvents` is expected to replay the job's current state first, so a
|
|
7
|
+
* job that finished before subscription still lands correctly.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
10
|
+
import type { ComponentGenPorts, HistoryEntry, JobEvent, JobKind, JobState, StartJobRequest } from '../ports.js'
|
|
11
|
+
|
|
12
|
+
export type GenPhase = 'idle' | 'running' | 'needs_confirmation' | 'completed' | 'failed' | 'cancelled'
|
|
13
|
+
|
|
14
|
+
export interface UseJobRunnerResult {
|
|
15
|
+
phase: GenPhase
|
|
16
|
+
progress: string
|
|
17
|
+
dimensions: Record<string, unknown> | null
|
|
18
|
+
pkgType: string | null
|
|
19
|
+
fileName: string | null
|
|
20
|
+
result: Record<string, unknown>
|
|
21
|
+
error: string
|
|
22
|
+
jobId: string | null
|
|
23
|
+
run: (req: StartJobRequest) => Promise<void>
|
|
24
|
+
cancel: () => Promise<void>
|
|
25
|
+
clear: () => void
|
|
26
|
+
/** Render an already-generated history entry in the 'completed' stage. */
|
|
27
|
+
loadHistory: (entry: HistoryEntry) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useJobRunner(ports: ComponentGenPorts): UseJobRunnerResult {
|
|
31
|
+
const [phase, setPhase] = useState<GenPhase>('idle')
|
|
32
|
+
const [progress, setProgress] = useState('')
|
|
33
|
+
const [dimensions, setDimensions] = useState<Record<string, unknown> | null>(null)
|
|
34
|
+
const [pkgType, setPkgType] = useState<string | null>(null)
|
|
35
|
+
const [fileName, setFileName] = useState<string | null>(null)
|
|
36
|
+
const [result, setResult] = useState<Record<string, unknown>>({})
|
|
37
|
+
const [error, setError] = useState('')
|
|
38
|
+
const [jobId, setJobId] = useState<string | null>(null)
|
|
39
|
+
const unsubRef = useRef<(() => void) | null>(null)
|
|
40
|
+
|
|
41
|
+
const cleanup = useCallback((): void => {
|
|
42
|
+
unsubRef.current?.()
|
|
43
|
+
unsubRef.current = null
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => cleanup, [cleanup])
|
|
47
|
+
|
|
48
|
+
const onEvent = useCallback((e: JobEvent): void => {
|
|
49
|
+
switch (e.type) {
|
|
50
|
+
case 'progress':
|
|
51
|
+
setProgress(e.message)
|
|
52
|
+
break
|
|
53
|
+
case 'needs_confirmation':
|
|
54
|
+
setPhase('needs_confirmation')
|
|
55
|
+
setDimensions(e.dimensions)
|
|
56
|
+
setPkgType(e.pkgType ?? null)
|
|
57
|
+
setFileName(e.fileName ?? null)
|
|
58
|
+
break
|
|
59
|
+
case 'completed':
|
|
60
|
+
setPhase('completed')
|
|
61
|
+
setResult(e.job.result ?? {})
|
|
62
|
+
setProgress('')
|
|
63
|
+
break
|
|
64
|
+
case 'failed':
|
|
65
|
+
setPhase('failed')
|
|
66
|
+
setError(e.error)
|
|
67
|
+
setResult(e.result ?? {})
|
|
68
|
+
setProgress('')
|
|
69
|
+
break
|
|
70
|
+
case 'cancelled':
|
|
71
|
+
setPhase('cancelled')
|
|
72
|
+
setProgress('')
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
const run = useCallback(async (req: StartJobRequest): Promise<void> => {
|
|
78
|
+
cleanup()
|
|
79
|
+
setPhase('running')
|
|
80
|
+
setProgress('')
|
|
81
|
+
setDimensions(null)
|
|
82
|
+
setPkgType(null)
|
|
83
|
+
setFileName(null)
|
|
84
|
+
setResult({})
|
|
85
|
+
setError('')
|
|
86
|
+
const job = await ports.startJob(req)
|
|
87
|
+
setJobId(job.id)
|
|
88
|
+
unsubRef.current = ports.jobEvents(job.id, onEvent)
|
|
89
|
+
}, [ports, cleanup, onEvent])
|
|
90
|
+
|
|
91
|
+
const cancel = useCallback(async (): Promise<void> => {
|
|
92
|
+
const id = jobId
|
|
93
|
+
if (!id) return
|
|
94
|
+
try { await ports.abortJob(id) } catch { /* best effort */ }
|
|
95
|
+
cleanup()
|
|
96
|
+
setPhase('cancelled')
|
|
97
|
+
}, [ports, jobId, cleanup])
|
|
98
|
+
|
|
99
|
+
const clear = useCallback((): void => {
|
|
100
|
+
cleanup()
|
|
101
|
+
setPhase('idle')
|
|
102
|
+
setProgress('')
|
|
103
|
+
setDimensions(null)
|
|
104
|
+
setResult({})
|
|
105
|
+
setError('')
|
|
106
|
+
setJobId(null)
|
|
107
|
+
}, [cleanup])
|
|
108
|
+
|
|
109
|
+
const loadHistory = useCallback((entry: HistoryEntry): void => {
|
|
110
|
+
cleanup()
|
|
111
|
+
setPhase('completed')
|
|
112
|
+
setProgress('')
|
|
113
|
+
setDimensions(null)
|
|
114
|
+
setPkgType(entry.input?.packageType ?? null)
|
|
115
|
+
setFileName(null)
|
|
116
|
+
setResult({
|
|
117
|
+
artifact: { id: entry.result?.artifactId },
|
|
118
|
+
filename: entry.result?.filename,
|
|
119
|
+
...(entry.input?.dimensions ? { dimensions: entry.input.dimensions } : {}),
|
|
120
|
+
})
|
|
121
|
+
setError('')
|
|
122
|
+
// jobId keys the result stage so re-opening a different entry re-renders.
|
|
123
|
+
setJobId(`history:${entry.id}`)
|
|
124
|
+
}, [cleanup])
|
|
125
|
+
|
|
126
|
+
return { phase, progress, dimensions, pkgType, fileName, result, error, jobId, run, cancel, clear, loadHistory }
|
|
127
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — public exports.
|
|
3
|
+
*
|
|
4
|
+
* The app is a portable React library: the DSH plugin imports these components
|
|
5
|
+
* and wires `ComponentGenPorts`; the standalone build mounts `main.tsx`.
|
|
6
|
+
*/
|
|
7
|
+
export { ComponentGenApp, type ComponentGenAppProps } from './App.js'
|
|
8
|
+
export { SymbolGenPage, type SymbolGenPageProps } from './pages/SymbolGenPage.js'
|
|
9
|
+
export { FootprintGenPage, type FootprintGenPageProps } from './pages/FootprintGenPage.js'
|
|
10
|
+
|
|
11
|
+
export { GeometryEditor, type GeometryEditorProps } from './components/GeometryEditor.js'
|
|
12
|
+
export { PreviewStage, type PreviewStageProps } from './components/PreviewStage.js'
|
|
13
|
+
export { ResultStage, type ResultStageProps } from './components/ResultStage.js'
|
|
14
|
+
export { UploadInput, type UploadInputProps, fileToDataUrl } from './components/UploadInput.js'
|
|
15
|
+
export { HistoryPanel, type HistoryPanelProps } from './components/HistoryPanel.js'
|
|
16
|
+
|
|
17
|
+
export { createHttpPorts, defaultArtifactsBase, type HttpPortsOptions } from './api/component-gen-client.js'
|
|
18
|
+
export type {
|
|
19
|
+
ComponentGenPorts,
|
|
20
|
+
ComponentGenAuthPort,
|
|
21
|
+
ComponentGenConfig,
|
|
22
|
+
ComponentGenPage,
|
|
23
|
+
HistoryEntry,
|
|
24
|
+
HistoryPage,
|
|
25
|
+
HistoryPatch,
|
|
26
|
+
HistoryQuery,
|
|
27
|
+
JobEvent,
|
|
28
|
+
JobInput,
|
|
29
|
+
JobKind,
|
|
30
|
+
JobState,
|
|
31
|
+
StartJobRequest,
|
|
32
|
+
} from './ports.js'
|
|
33
|
+
|
|
34
|
+
export { translateFor, translate, defaultT, type Translate } from './copy/index.js'
|
|
35
|
+
export { ZH, EN } from './copy/index.js'
|
|
36
|
+
|
|
37
|
+
export { injectAppStyles, removeAppStyles, APP_STYLE_ID } from './styles/inject.js'
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — standalone browser entry (vite).
|
|
3
|
+
*
|
|
4
|
+
* Served by `@huaqiu/component-gen-server` alongside the API. Opens the page
|
|
5
|
+
* from `?page=symbol|footprint`. Auth is read from the dsh-auth session route
|
|
6
|
+
* the standalone server mounts (`/api/v1/huaqiu/auth/session`); login opens
|
|
7
|
+
* the official auth.eda.cn page. See the standalone README for the auth
|
|
8
|
+
* flow (the production DSH/EDA integrations use the real dsh-auth client).
|
|
9
|
+
*/
|
|
10
|
+
import { StrictMode } from 'react'
|
|
11
|
+
import { createRoot } from 'react-dom/client'
|
|
12
|
+
import { ComponentGenApp } from './App.js'
|
|
13
|
+
import { createHttpPorts } from './api/component-gen-client.js'
|
|
14
|
+
import { injectAppStyles } from './styles/inject.js'
|
|
15
|
+
import type { ComponentGenAuthPort, ComponentGenPage, ComponentGenPorts } from './ports.js'
|
|
16
|
+
|
|
17
|
+
injectAppStyles()
|
|
18
|
+
|
|
19
|
+
const AUTH_BASE = '/api/v1/huaqiu/auth'
|
|
20
|
+
|
|
21
|
+
/** Read auth state from the dsh-auth session route (standalone server). */
|
|
22
|
+
function createStandaloneAuth(): ComponentGenAuthPort {
|
|
23
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
24
|
+
const listeners = new Set<(authenticated: boolean) => void>()
|
|
25
|
+
const readState = async (): Promise<{ authenticated: boolean; user: { nickname?: string } | null }> => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${AUTH_BASE}/session`, { headers: { accept: 'application/json' } })
|
|
28
|
+
if (!res.ok) return { authenticated: false, user: null }
|
|
29
|
+
const body = (await res.json()) as { authenticated?: boolean; user?: { nickname?: string } | null }
|
|
30
|
+
return { authenticated: body.authenticated === true, user: body.user ?? null }
|
|
31
|
+
} catch {
|
|
32
|
+
return { authenticated: false, user: null }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const startPolling = (): void => {
|
|
36
|
+
if (pollTimer) return
|
|
37
|
+
let last = false
|
|
38
|
+
void readState().then((s) => { last = s.authenticated })
|
|
39
|
+
pollTimer = setInterval(() => {
|
|
40
|
+
void readState().then((s) => {
|
|
41
|
+
if (s.authenticated !== last) {
|
|
42
|
+
last = s.authenticated
|
|
43
|
+
for (const cb of [...listeners]) cb(s.authenticated)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}, 2000)
|
|
47
|
+
}
|
|
48
|
+
startPolling()
|
|
49
|
+
return {
|
|
50
|
+
async isAuthenticated() { return (await readState()).authenticated },
|
|
51
|
+
async getUserInfo() { return (await readState()).user },
|
|
52
|
+
async login() {
|
|
53
|
+
// Official login page in a new tab; the dsh-auth session route is the
|
|
54
|
+
// source of truth the poller watches.
|
|
55
|
+
window.open('https://auth.eda.cn/', '_blank', 'noopener')
|
|
56
|
+
},
|
|
57
|
+
onAuthStateChanged(cb) {
|
|
58
|
+
listeners.add(cb)
|
|
59
|
+
return () => { listeners.delete(cb) }
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function params(): URLSearchParams {
|
|
65
|
+
return new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mount(): void {
|
|
69
|
+
const rootEl = document.getElementById('root')
|
|
70
|
+
if (!rootEl) return
|
|
71
|
+
const page: ComponentGenPage = params().get('page') === 'symbol' ? 'symbol' : 'footprint'
|
|
72
|
+
const lang = params().get('lang') ?? undefined
|
|
73
|
+
const ports: ComponentGenPorts = createHttpPorts({
|
|
74
|
+
base: '/api/v1/huaqiu/component-gen',
|
|
75
|
+
artifactsBase: '/api/v1/huaqiu/artifacts',
|
|
76
|
+
auth: createStandaloneAuth(),
|
|
77
|
+
})
|
|
78
|
+
createRoot(rootEl).render(
|
|
79
|
+
<StrictMode>
|
|
80
|
+
<ComponentGenApp ports={ports} page={page} lang={lang} />
|
|
81
|
+
</StrictMode>,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mount()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — Footprint generation page.
|
|
3
|
+
*
|
|
4
|
+
* Two workflows, both driven by the app itself (single-HIL):
|
|
5
|
+
* 1. needs_confirmation — extract → dimension editor → confirm →
|
|
6
|
+
* generate-footprint from the human-approved values.
|
|
7
|
+
* 2. direct generation — extract returns a standard footprint immediately
|
|
8
|
+
* (fast path) → preview + download.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
11
|
+
import type { ComponentGenConfig, ComponentGenPorts, ReopenRequest } from '../ports.js'
|
|
12
|
+
import type { Translate } from '../copy/index.js'
|
|
13
|
+
import type { DimensionValues } from '../utils/dims.js'
|
|
14
|
+
import { UploadInput } from '../components/UploadInput.js'
|
|
15
|
+
import { GeometryEditor } from '../components/GeometryEditor.js'
|
|
16
|
+
import { ResultStage } from '../components/ResultStage.js'
|
|
17
|
+
import { useAuthGate } from '../hooks/useAuthGate.js'
|
|
18
|
+
import { useJobRunner } from '../hooks/useJobRunner.js'
|
|
19
|
+
|
|
20
|
+
export interface FootprintGenPageProps {
|
|
21
|
+
ports: ComponentGenPorts
|
|
22
|
+
t: Translate
|
|
23
|
+
/** reopen a generated history entry into the completed stage. */
|
|
24
|
+
reopen?: ReopenRequest | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FootprintGenPage({ ports, t, reopen = null }: FootprintGenPageProps): ReactElement {
|
|
28
|
+
const auth = useAuthGate(ports)
|
|
29
|
+
const runner = useJobRunner(ports)
|
|
30
|
+
const [config, setConfig] = useState<ComponentGenConfig | null>(null)
|
|
31
|
+
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null)
|
|
32
|
+
const [file, setFile] = useState<File | null>(null)
|
|
33
|
+
const [hint, setHint] = useState('')
|
|
34
|
+
|
|
35
|
+
// Stable refs so the reopen effect only re-runs when the request changes.
|
|
36
|
+
const runnerRef = useRef(runner)
|
|
37
|
+
runnerRef.current = runner
|
|
38
|
+
const portsRef = useRef(ports)
|
|
39
|
+
portsRef.current = ports
|
|
40
|
+
|
|
41
|
+
// Reopen: load the generated artifact into the result stage and restore the
|
|
42
|
+
// source image + package-type hint so the user can inspect / regenerate.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!reopen) return
|
|
45
|
+
const { entry } = reopen
|
|
46
|
+
runnerRef.current.loadHistory(entry)
|
|
47
|
+
if (entry.input?.imageId) {
|
|
48
|
+
portsRef.current.inputImage(entry.input.imageId)
|
|
49
|
+
.then((dataUrl) => setImageDataUrl(dataUrl))
|
|
50
|
+
.catch(() => { /* best effort */ })
|
|
51
|
+
}
|
|
52
|
+
if (entry.input?.packageType) setHint(entry.input.packageType)
|
|
53
|
+
}, [reopen])
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
ports.config().then(setConfig).catch(() => { /* best effort */ })
|
|
57
|
+
}, [ports])
|
|
58
|
+
|
|
59
|
+
const maxBytes = config?.limits.imageBytes ?? 4 * 1024 * 1024
|
|
60
|
+
const authed = auth.phase === 'authenticated'
|
|
61
|
+
const busy = runner.phase === 'running'
|
|
62
|
+
|
|
63
|
+
const extract = useCallback((): void => {
|
|
64
|
+
if (!imageDataUrl) return
|
|
65
|
+
void runner.run({
|
|
66
|
+
kind: 'extract-footprint',
|
|
67
|
+
input: {
|
|
68
|
+
imageDataUrl,
|
|
69
|
+
...(hint.trim() ? { packageType: hint.trim() } : {}),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}, [imageDataUrl, hint, runner])
|
|
73
|
+
|
|
74
|
+
const confirmDimensions = useCallback((values: DimensionValues, edited: Record<string, boolean>): void => {
|
|
75
|
+
void runner.run({
|
|
76
|
+
kind: 'generate-footprint',
|
|
77
|
+
input: {
|
|
78
|
+
// Re-stamp the source image so the server keeps an authoritative copy
|
|
79
|
+
// and the generated history entry can reopen it (the extract job's
|
|
80
|
+
// entry is not the one shown in history).
|
|
81
|
+
imageDataUrl: imageDataUrl ?? undefined,
|
|
82
|
+
packageType: (runner.pkgType ?? hint.trim()) || undefined,
|
|
83
|
+
fileName: runner.fileName ?? undefined,
|
|
84
|
+
dimensions: values,
|
|
85
|
+
edited,
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
}, [runner, hint, imageDataUrl])
|
|
89
|
+
|
|
90
|
+
const cancelConfirm = useCallback((): void => {
|
|
91
|
+
runner.clear()
|
|
92
|
+
}, [runner])
|
|
93
|
+
|
|
94
|
+
const resultKey = useMemo(() => `${runner.jobId ?? 'none'}:${runner.phase}`, [runner.jobId, runner.phase])
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="cga-app">
|
|
98
|
+
<div className="cga-panel">
|
|
99
|
+
<div className="cga-panel__body">
|
|
100
|
+
{auth.phase === 'unknown' ? <div className="cga-progress"><span className="cga-spinner" />{t('app.loading')}</div> : null}
|
|
101
|
+
{auth.phase === 'unauthenticated'
|
|
102
|
+
? (
|
|
103
|
+
<div className="cga-auth">
|
|
104
|
+
<span>{t('auth.loginRequired')}</span>
|
|
105
|
+
<button type="button" className="cga-btn" onClick={auth.login}>{t('auth.login')}</button>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
: null}
|
|
109
|
+
|
|
110
|
+
{runner.phase !== 'needs_confirmation'
|
|
111
|
+
? (
|
|
112
|
+
<>
|
|
113
|
+
<UploadInput
|
|
114
|
+
maxBytes={maxBytes}
|
|
115
|
+
t={t}
|
|
116
|
+
disabled={!authed || busy}
|
|
117
|
+
imageDataUrl={imageDataUrl}
|
|
118
|
+
file={file}
|
|
119
|
+
onFile={(f, url) => { setFile(f); setImageDataUrl(url) }}
|
|
120
|
+
/>
|
|
121
|
+
<div className="cga-field">
|
|
122
|
+
<label className="cga-field__label">{t('footprint.hintPlaceholder')}</label>
|
|
123
|
+
<input
|
|
124
|
+
className="cga-field__input"
|
|
125
|
+
type="text"
|
|
126
|
+
value={hint}
|
|
127
|
+
disabled={!authed || busy}
|
|
128
|
+
onChange={(ev) => setHint(ev.target.value)}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="cga-actions">
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
className="cga-btn cga-btn--primary"
|
|
135
|
+
disabled={!authed || !imageDataUrl || busy}
|
|
136
|
+
onClick={extract}
|
|
137
|
+
>
|
|
138
|
+
{busy ? t('footprint.extractProgress') : t('footprint.extract')}
|
|
139
|
+
</button>
|
|
140
|
+
{busy
|
|
141
|
+
? <button type="button" className="cga-btn" onClick={() => void runner.cancel()}>{t('editor.cancelLabel')}</button>
|
|
142
|
+
: null}
|
|
143
|
+
</div>
|
|
144
|
+
</>
|
|
145
|
+
)
|
|
146
|
+
: null}
|
|
147
|
+
|
|
148
|
+
{runner.phase === 'running' && runner.jobId
|
|
149
|
+
? <div className="cga-progress"><span className="cga-spinner" />{runner.progress || t('footprint.extractProgress')}</div>
|
|
150
|
+
: null}
|
|
151
|
+
|
|
152
|
+
{runner.phase === 'needs_confirmation' && runner.dimensions
|
|
153
|
+
? (
|
|
154
|
+
<>
|
|
155
|
+
<div className="cga-banner cga-banner--info">{t('footprint.needsConfirmation')}</div>
|
|
156
|
+
<GeometryEditor
|
|
157
|
+
dimensions={runner.dimensions}
|
|
158
|
+
pkgType={runner.pkgType}
|
|
159
|
+
fileName={runner.fileName}
|
|
160
|
+
disabled={false}
|
|
161
|
+
t={t}
|
|
162
|
+
onConfirm={confirmDimensions}
|
|
163
|
+
onCancel={cancelConfirm}
|
|
164
|
+
/>
|
|
165
|
+
</>
|
|
166
|
+
)
|
|
167
|
+
: null}
|
|
168
|
+
|
|
169
|
+
{runner.phase === 'failed'
|
|
170
|
+
? (
|
|
171
|
+
<div className="cga-banner cga-banner--error">
|
|
172
|
+
{t('footprint.failed')}: {runner.error}
|
|
173
|
+
{runner.result?.status === 'needs_auth' ? ` (${t('auth.loginRequired')})` : ''}
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
: null}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{runner.phase === 'completed'
|
|
181
|
+
? <ResultStage ports={ports} kind="footprint" result={runner.result} t={t} srcKey={resultKey} />
|
|
182
|
+
: null}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — Symbol generation page.
|
|
3
|
+
*
|
|
4
|
+
* Upload an image (required by the symbol-from-image generator) + optional
|
|
5
|
+
* instruction → `runGenerateSymbol` via the component-gen server → live
|
|
6
|
+
* progress → preview + download + history.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
9
|
+
import type { ComponentGenConfig, ComponentGenPorts, ReopenRequest } from '../ports.js'
|
|
10
|
+
import type { Translate } from '../copy/index.js'
|
|
11
|
+
import { UploadInput } from '../components/UploadInput.js'
|
|
12
|
+
import { ResultStage } from '../components/ResultStage.js'
|
|
13
|
+
import { useAuthGate } from '../hooks/useAuthGate.js'
|
|
14
|
+
import { useJobRunner } from '../hooks/useJobRunner.js'
|
|
15
|
+
|
|
16
|
+
export interface SymbolGenPageProps {
|
|
17
|
+
ports: ComponentGenPorts
|
|
18
|
+
t: Translate
|
|
19
|
+
/** reopen a generated history entry into the completed stage. */
|
|
20
|
+
reopen?: ReopenRequest | null
|
|
21
|
+
/** the app calls back to switch tabs (not used on the symbol page). */
|
|
22
|
+
onClose?: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SymbolGenPage({ ports, t, reopen = null }: SymbolGenPageProps): ReactElement {
|
|
26
|
+
const auth = useAuthGate(ports)
|
|
27
|
+
const runner = useJobRunner(ports)
|
|
28
|
+
const [config, setConfig] = useState<ComponentGenConfig | null>(null)
|
|
29
|
+
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null)
|
|
30
|
+
const [file, setFile] = useState<File | null>(null)
|
|
31
|
+
const [instruction, setInstruction] = useState('')
|
|
32
|
+
|
|
33
|
+
// Stable refs so the reopen effect only re-runs when the request changes.
|
|
34
|
+
const runnerRef = useRef(runner)
|
|
35
|
+
runnerRef.current = runner
|
|
36
|
+
const portsRef = useRef(ports)
|
|
37
|
+
portsRef.current = ports
|
|
38
|
+
|
|
39
|
+
// Reopen: load the generated artifact into the result stage and restore the
|
|
40
|
+
// source image + instruction so the user can inspect / regenerate.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!reopen) return
|
|
43
|
+
const { entry } = reopen
|
|
44
|
+
runnerRef.current.loadHistory(entry)
|
|
45
|
+
if (entry.input?.imageId) {
|
|
46
|
+
portsRef.current.inputImage(entry.input.imageId)
|
|
47
|
+
.then((dataUrl) => setImageDataUrl(dataUrl))
|
|
48
|
+
.catch(() => { /* best effort */ })
|
|
49
|
+
}
|
|
50
|
+
if (entry.input?.instruction) setInstruction(entry.input.instruction)
|
|
51
|
+
}, [reopen])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
ports.config().then(setConfig).catch(() => { /* best effort */ })
|
|
55
|
+
}, [ports])
|
|
56
|
+
|
|
57
|
+
const maxBytes = config?.limits.imageBytes ?? 4 * 1024 * 1024
|
|
58
|
+
const authed = auth.phase === 'authenticated'
|
|
59
|
+
const canGenerate = authed && !!imageDataUrl && runner.phase !== 'running'
|
|
60
|
+
|
|
61
|
+
const generate = (): void => {
|
|
62
|
+
if (!imageDataUrl) return
|
|
63
|
+
void runner.run({
|
|
64
|
+
kind: 'symbol',
|
|
65
|
+
input: { imageDataUrl, ...(instruction.trim() ? { instruction: instruction.trim() } : {}) },
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const resultKey = useMemo(() => `${runner.jobId ?? 'none'}:${runner.phase}`, [runner.jobId, runner.phase])
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="cga-app">
|
|
73
|
+
<div className="cga-panel">
|
|
74
|
+
<div className="cga-panel__body">
|
|
75
|
+
{auth.phase === 'unknown' ? <div className="cga-progress"><span className="cga-spinner" />{t('app.loading')}</div> : null}
|
|
76
|
+
{auth.phase === 'unauthenticated'
|
|
77
|
+
? (
|
|
78
|
+
<div className="cga-auth">
|
|
79
|
+
<span>{t('auth.loginRequired')}</span>
|
|
80
|
+
<button type="button" className="cga-btn" onClick={auth.login}>{t('auth.login')}</button>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
: null}
|
|
84
|
+
|
|
85
|
+
<UploadInput
|
|
86
|
+
maxBytes={maxBytes}
|
|
87
|
+
t={t}
|
|
88
|
+
disabled={!authed || runner.phase === 'running'}
|
|
89
|
+
imageDataUrl={imageDataUrl}
|
|
90
|
+
file={file}
|
|
91
|
+
onFile={(f, url) => { setFile(f); setImageDataUrl(url) }}
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
<div className="cga-field">
|
|
95
|
+
<label className="cga-field__label">{t('symbol.instructionPlaceholder')}</label>
|
|
96
|
+
<input
|
|
97
|
+
className="cga-field__input"
|
|
98
|
+
type="text"
|
|
99
|
+
value={instruction}
|
|
100
|
+
disabled={!authed || runner.phase === 'running'}
|
|
101
|
+
onChange={(ev) => setInstruction(ev.target.value)}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="cga-actions">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className="cga-btn cga-btn--primary"
|
|
109
|
+
disabled={!canGenerate}
|
|
110
|
+
onClick={generate}
|
|
111
|
+
>
|
|
112
|
+
{runner.phase === 'running' ? t('symbol.progress') : (runner.phase === 'completed' ? t('symbol.regenerate') : t('symbol.generate'))}
|
|
113
|
+
</button>
|
|
114
|
+
{runner.phase === 'running'
|
|
115
|
+
? <button type="button" className="cga-btn" onClick={() => void runner.cancel()}>{t('editor.cancelLabel')}</button>
|
|
116
|
+
: null}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{runner.phase === 'running' ? <div className="cga-progress"><span className="cga-spinner" />{runner.progress || t('symbol.progress')}</div> : null}
|
|
120
|
+
{runner.phase === 'failed'
|
|
121
|
+
? (
|
|
122
|
+
<div className="cga-banner cga-banner--error">
|
|
123
|
+
{t('symbol.failed')}: {runner.error}
|
|
124
|
+
{runner.result?.status === 'needs_auth' ? ` (${t('auth.loginRequired')})` : ''}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
: null}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{runner.phase === 'completed'
|
|
132
|
+
? <ResultStage ports={ports} kind="symbol" result={runner.result} t={t} srcKey={resultKey} />
|
|
133
|
+
: null}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|