feat: add team-code UI import and API integration

This commit is contained in:
2026-02-28 21:48:06 +08:00
parent e0d38d4ddd
commit fb0107fe90
3 changed files with 275 additions and 2 deletions

View File

@@ -56,6 +56,7 @@
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jsqr": "^1.4.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.3.10", "vue": "^3.3.10",
"vue-i18n": "^11.1.1", "vue-i18n": "^11.1.1",

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="toolbar" :class="{ 'toolbar--embed': props.isEmbed }"> <div class="toolbar" :class="{ 'toolbar--embed': props.isEmbed }">
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button icon="Upload" type="primary" @click="handleImport">{{ t('import') }}</el-button> <el-button icon="Upload" type="primary" @click="openImportDialog">{{ t('import') }}</el-button>
<el-button icon="Download" type="primary" @click="handleExport">{{ t('export') }}</el-button> <el-button icon="Download" type="primary" @click="handleExport">{{ t('export') }}</el-button>
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button> <el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button> <el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
@@ -113,6 +113,72 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="state.showImportDialog" title="导入数据" width="560px">
<el-form label-width="88px" class="import-form">
<el-form-item label="导入来源">
<el-radio-group v-model="importSource">
<el-radio-button label="json">JSON 文件</el-radio-button>
<el-radio-button label="teamCode">阵容码</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="importSource === 'teamCode'" label="阵容码">
<el-input
v-model="teamCodeInput"
type="textarea"
:rows="7"
placeholder="请粘贴 #TA# 开头的阵容码"
/>
</el-form-item>
<el-form-item v-if="importSource === 'teamCode'" label="二维码">
<div class="team-code-qr-actions">
<input
ref="teamCodeQrInputRef"
type="file"
accept="image/*"
class="asset-upload-input"
@change="handleTeamCodeQrImport"
/>
<el-button
type="primary"
plain
:loading="state.decodingTeamCodeQr"
@click="triggerTeamCodeQrImport"
>
选择二维码图片
</el-button>
<span class="team-code-qr-tip">支持从截图或相册图片识别官方阵容码二维码</span>
</div>
</el-form-item>
<el-alert
v-if="importSource === 'teamCode'"
type="info"
:closable="false"
show-icon
title="支持粘贴阵容码字符串,或上传二维码图片自动识别。"
/>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showImportDialog = false">取消</el-button>
<el-button
v-if="importSource === 'json'"
type="primary"
@click="triggerJsonFileImport"
>
选择 JSON 文件
</el-button>
<el-button
v-else
type="primary"
:loading="state.importingTeamCode"
@click="handleTeamCodeImport"
>
导入阵容码
</el-button>
</span>
</template>
</el-dialog>
<!-- 素材管理对话框 --> <!-- 素材管理对话框 -->
<el-dialog v-model="state.showAssetManagerDialog" title="素材管理" width="70%"> <el-dialog v-model="state.showAssetManagerDialog" title="素材管理" width="70%">
<div class="asset-manager-actions"> <div class="asset-manager-actions">
@@ -348,6 +414,7 @@ import {
type ExpressionRuleDefinition, type ExpressionRuleDefinition,
type RuleVariableDefinition type RuleVariableDefinition
} from '@/configs/groupRules'; } from '@/configs/groupRules';
import { convertTeamCodeToRootDocument, decodeTeamCodeFromQrImage } from '@/utils/teamCodeService';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
isEmbed?: boolean; isEmbed?: boolean;
@@ -379,10 +446,16 @@ const state = reactive({
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态 showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态 showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
showImportDialog: false, // 控制导入来源对话框
importingTeamCode: false, // 阵容码导入中
decodingTeamCodeQr: false, // 阵容码二维码识别中
showAssetManagerDialog: false, // 控制素材管理对话框的显示状态 showAssetManagerDialog: false, // 控制素材管理对话框的显示状态
showRuleManagerDialog: false, // 控制规则管理对话框的显示状态 showRuleManagerDialog: false, // 控制规则管理对话框的显示状态
previewDataContent: '', // 存储预览的数据内容 previewDataContent: '', // 存储预览的数据内容
}); });
const importSource = ref<'json' | 'teamCode'>('json');
const teamCodeInput = ref('');
const teamCodeQrInputRef = ref<HTMLInputElement | null>(null);
const assetLibraries = ASSET_LIBRARIES.map((item) => ({ const assetLibraries = ASSET_LIBRARIES.map((item) => ({
id: item.id, id: item.id,
label: `${item.label}素材` 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'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
@@ -936,10 +1024,56 @@ const handleImport = () => {
}; };
reader.readAsText(file); reader.readAsText(file);
} }
target.value = '';
}; };
input.click(); 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 = () => { const handleResetWorkspace = () => {
ElMessageBox.confirm('确定重置当前工作区?该操作不可撤销', '提示', { ElMessageBox.confirm('确定重置当前工作区?该操作不可撤销', '提示', {
confirmButtonText: '重置', confirmButtonText: '重置',
@@ -1221,6 +1355,22 @@ const handleClose = (done) => {
margin-bottom: 12px; 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 { .asset-upload-input {
display: none; display: none;
} }

View File

@@ -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<string, unknown>
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<HTMLImageElement> => {
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<string> => {
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<UnknownRecord> => {
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
}