diff --git a/package.json b/package.json index 6c4b83c..de4aee6 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@vueup/vue-quill": "^1.2.0", "element-plus": "^2.9.1", "html2canvas": "^1.4.1", + "jsqr": "^1.4.0", "pinia": "^3.0.1", "vue": "^3.3.10", "vue-i18n": "^11.1.1", diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 0597202..ee7ef81 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -1,7 +1,7 @@ + + + + + JSON 文件 + 阵容码 + + + + + + +
+ + + 选择二维码图片 + + 支持从截图或相册图片识别官方阵容码二维码 +
+
+ +
+ +
+
@@ -348,6 +414,7 @@ import { type ExpressionRuleDefinition, type RuleVariableDefinition } from '@/configs/groupRules'; +import { convertTeamCodeToRootDocument, decodeTeamCodeFromQrImage } from '@/utils/teamCodeService'; const props = withDefaults(defineProps<{ isEmbed?: boolean; @@ -379,10 +446,16 @@ const state = reactive({ showUpdateLogDialog: false, // 控制更新日志对话框的显示状态 showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 showDataPreviewDialog: false, // 控制数据预览对话框的显示状态 + showImportDialog: false, // 控制导入来源对话框 + importingTeamCode: false, // 阵容码导入中 + decodingTeamCodeQr: false, // 阵容码二维码识别中 showAssetManagerDialog: false, // 控制素材管理对话框的显示状态 showRuleManagerDialog: false, // 控制规则管理对话框的显示状态 previewDataContent: '', // 存储预览的数据内容 }); +const importSource = ref<'json' | 'teamCode'>('json'); +const teamCodeInput = ref(''); +const teamCodeQrInputRef = ref(null); const assetLibraries = ASSET_LIBRARIES.map((item) => ({ id: item.id, label: `${item.label}素材` @@ -914,7 +987,22 @@ const copyDataToClipboard = async () => { } }; -const handleImport = () => { +const openImportDialog = () => { + importSource.value = 'json'; + teamCodeInput.value = ''; + state.showImportDialog = true; +}; + +const triggerJsonFileImport = () => { + state.showImportDialog = false; + handleJsonImport(); +}; + +const triggerTeamCodeQrImport = () => { + teamCodeQrInputRef.value?.click(); +}; + +const handleJsonImport = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; @@ -936,10 +1024,56 @@ const handleImport = () => { }; reader.readAsText(file); } + target.value = ''; }; input.click(); }; +const handleTeamCodeImport = async () => { + const rawTeamCode = teamCodeInput.value.trim(); + if (!rawTeamCode) { + showMessage('warning', '请先粘贴阵容码'); + return; + } + + state.importingTeamCode = true; + try { + const rootDocument = await convertTeamCodeToRootDocument(rawTeamCode); + filesStore.importData(rootDocument); + refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(阵容码导入)'); + state.showImportDialog = false; + teamCodeInput.value = ''; + showMessage('success', '阵容码导入成功'); + } catch (error: any) { + console.error('阵容码导入失败:', error); + showMessage('error', error?.message || '阵容码导入失败'); + } finally { + state.importingTeamCode = false; + } +}; + +const handleTeamCodeQrImport = async (event: Event) => { + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; + if (!file) { + if (target) target.value = ''; + return; + } + + state.decodingTeamCodeQr = true; + try { + const decodedTeamCode = await decodeTeamCodeFromQrImage(file); + teamCodeInput.value = decodedTeamCode; + showMessage('success', '二维码识别成功,已填入阵容码'); + } catch (error: any) { + console.error('二维码识别失败:', error); + showMessage('error', error?.message || '二维码识别失败'); + } finally { + state.decodingTeamCodeQr = false; + if (target) target.value = ''; + } +}; + const handleResetWorkspace = () => { ElMessageBox.confirm('确定重置当前工作区?该操作不可撤销', '提示', { confirmButtonText: '重置', @@ -1221,6 +1355,22 @@ const handleClose = (done) => { margin-bottom: 12px; } +.import-form { + margin-top: 4px; +} + +.team-code-qr-actions { + width: 100%; + display: flex; + align-items: center; + gap: 10px; +} + +.team-code-qr-tip { + font-size: 12px; + color: #606266; +} + .asset-upload-input { display: none; } diff --git a/src/utils/teamCodeService.ts b/src/utils/teamCodeService.ts new file mode 100644 index 0000000..8c11a5e --- /dev/null +++ b/src/utils/teamCodeService.ts @@ -0,0 +1,122 @@ +import jsQR from 'jsqr' + +const DEFAULT_TEAM_CODE_SERVICE_URL = 'http://127.0.0.1:8788/api/team-code/convert' + +export const TEAM_CODE_SERVICE_URL = ( + import.meta.env.VITE_TEAM_CODE_SERVICE_URL as string | undefined +)?.trim() || DEFAULT_TEAM_CODE_SERVICE_URL + +type UnknownRecord = Record + +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') + +const readErrorMessageFromPayload = (payload: unknown): string => { + if (!payload || typeof payload !== 'object') return '' + const record = payload as UnknownRecord + return normalizeText(record.error) || normalizeText(record.message) +} + +const pickRootDocument = (payload: unknown): UnknownRecord | null => { + if (!payload || typeof payload !== 'object') return null + const root = payload as UnknownRecord + if (Array.isArray(root.fileList)) return root + + const candidates = [root.data, root.root, root.payload] + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') continue + if (Array.isArray((candidate as UnknownRecord).fileList)) { + return candidate as UnknownRecord + } + } + return null +} + +const createImageElementFromFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const image = new Image() + image.onload = () => { + URL.revokeObjectURL(url) + resolve(image) + } + image.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('无法读取二维码图片,请确认图片格式正确')) + } + image.src = url + }) +} + +const extractImageData = (image: HTMLImageElement, scale = 1): ImageData => { + const width = Math.max(1, Math.floor(image.naturalWidth * scale)) + const height = Math.max(1, Math.floor(image.naturalHeight * scale)) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) { + throw new Error('浏览器无法创建 Canvas 2D 上下文') + } + context.drawImage(image, 0, 0, width, height) + return context.getImageData(0, 0, width, height) +} + +// 前端仅负责二维码图片识别为字符串,不做阵容码协议解析 +export const decodeTeamCodeFromQrImage = async (file: File): Promise => { + if (!file) { + throw new Error('请选择二维码图片') + } + + const image = await createImageElementFromFile(file) + const scales = [1, 0.75, 0.5, 1.5, 2] + + for (const scale of scales) { + const imageData = extractImageData(image, scale) + const result = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: 'attemptBoth', + }) + const rawValue = normalizeText(result?.data) + if (rawValue) return rawValue + } + + throw new Error('未识别到有效二维码,请确认图片清晰且仅包含一个阵容码二维码') +} + +// 阵容码字符串 -> RootDocument 由后端服务完成 +export const convertTeamCodeToRootDocument = async ( + teamCode: string, + serviceUrl = TEAM_CODE_SERVICE_URL, +): Promise => { + const normalizedTeamCode = normalizeText(teamCode) + if (!normalizedTeamCode) { + throw new Error('请输入阵容码') + } + + const response = await fetch(serviceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ teamCode: normalizedTeamCode }), + }) + + let payload: unknown = null + try { + payload = await response.json() + } catch (error) { + console.error('解析后端响应失败:', error) + throw new Error('阵容码服务返回了非 JSON 响应') + } + + if (!response.ok) { + const errorMessage = readErrorMessageFromPayload(payload) + throw new Error(errorMessage || '阵容码解析失败') + } + + const root = pickRootDocument(payload) + if (!root) { + throw new Error('阵容码解析成功,但响应不包含可导入的 fileList') + } + return root +} +