first commit: 初始化1818-admin项目

This commit is contained in:
2026-02-13 17:47:58 +08:00
commit 67091b730d
59 changed files with 14102 additions and 0 deletions

788
src/views/ai/debug.vue Normal file
View File

@@ -0,0 +1,788 @@
<template>
<div class="debug-page">
<!-- 顶部标题栏 -->
<div class="page-header">
<div class="header-left">
<a-button type="text" @click="$router.back()">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<h1>AI接口调试</h1>
<span class="subtitle">在线测试AI模型接口</span>
</div>
<div class="header-right">
<a-button @click="handleClearAll">
<template #icon><ClearOutlined /></template>
重置
</a-button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 模型选择区 -->
<div class="section">
<div class="section-title">选择模型</div>
<div class="model-selector">
<a-select
v-model:value="selectedModelId"
placeholder="请选择要调试的AI模型"
show-search
size="large"
style="width: 400px;"
:filter-option="filterOption"
@change="handleModelChange"
>
<a-select-opt-group v-for="group in groupedModels" :key="group.name">
<template #label>{{ group.name }}</template>
<a-select-option v-for="model in group.models" :key="model.id" :value="model.id">
<span>{{ model.name }}</span>
<span class="model-code">{{ model.code }}</span>
</a-select-option>
</a-select-opt-group>
</a-select>
<div v-if="selectedModel" class="model-meta">
<span class="meta-item">
<span class="meta-label">类型</span>
<span class="meta-value">{{ getTypeLabel(selectedModel.type) }}</span>
</span>
<span class="meta-divider">|</span>
<span class="meta-item">
<span class="meta-label">积分</span>
<span class="meta-value">{{ selectedModel.pointsCost }}/</span>
</span>
<span v-if="selectedModel.workflowType !== 'direct'" class="meta-divider">|</span>
<span v-if="selectedModel.workflowType !== 'direct'" class="meta-item">
<span class="meta-label">工作流</span>
<span class="meta-value">{{ selectedModel.workflowType }}</span>
</span>
</div>
</div>
</div>
<!-- 参数配置区 -->
<div class="section">
<div class="section-title">输入参数</div>
<div v-if="selectedModel && inputParams.length > 0" class="params-form">
<a-form layout="vertical">
<a-row :gutter="24">
<template v-for="param in inputParams" :key="param.name">
<!-- 文本输入 -->
<a-col :span="param.type === 'textarea' ? 24 : 12" v-if="param.type === 'text' || param.type === 'input'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<a-input v-model:value="formData[param.name]" :placeholder="param.placeholder || `请输入${param.label}`" />
</a-form-item>
</a-col>
<!-- 文本域 -->
<a-col :span="24" v-else-if="param.type === 'textarea'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<a-textarea v-model:value="formData[param.name]" :placeholder="param.placeholder || `请输入${param.label}`" :rows="4" show-count :maxlength="2000" />
</a-form-item>
</a-col>
<!-- 下拉选择 -->
<a-col :span="12" v-else-if="param.type === 'select'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<a-select v-model:value="formData[param.name]" :placeholder="param.placeholder || `请选择${param.label}`">
<a-select-option v-for="opt in param.options" :key="opt" :value="opt">{{ opt }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<!-- 数字输入 -->
<a-col :span="12" v-else-if="param.type === 'number'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<a-input-number v-model:value="formData[param.name]" :min="param.min" :max="param.max" style="width: 100%;" />
</a-form-item>
</a-col>
<!-- 单图上传 -->
<a-col :span="12" v-else-if="param.type === 'image'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<div class="image-upload-area">
<a-upload :file-list="getImageFileList(param.name)" list-type="picture-card" :before-upload="(file) => handleBeforeUpload(file, param.name, false)" @remove="() => handleRemoveImage(param.name)" :max-count="1">
<div v-if="!formData[param.name]" class="upload-placeholder"><PlusOutlined /><span>上传</span></div>
</a-upload>
<span class="upload-hint">JPG/PNG/GIF, 最大10MB</span>
</div>
</a-form-item>
</a-col>
<!-- 多图上传 -->
<a-col :span="24" v-else-if="param.type === 'images'">
<a-form-item :required="param.required">
<template #label><span class="param-label">{{ param.label }}</span></template>
<div class="image-upload-area">
<a-upload :file-list="getImagesFileList(param.name)" list-type="picture-card" :before-upload="(file) => handleBeforeUpload(file, param.name, true)" @remove="(file) => handleRemoveImages(param.name, file)" :max-count="param.maxCount || 5" multiple>
<div class="upload-placeholder"><PlusOutlined /><span>上传</span></div>
</a-upload>
<span class="upload-hint">最多{{ param.maxCount || 5 }}张</span>
</div>
</a-form-item>
</a-col>
</template>
</a-row>
</a-form>
</div>
<div v-else class="empty-params">
<span class="empty-text">请先选择模型</span>
</div>
<!-- 发送按钮 -->
<div class="submit-area">
<a-button type="primary" size="large" :loading="debugging" @click="handleDebug" :disabled="!canSubmit">
<template #icon><SendOutlined /></template>
{{ debugging ? '请求中...' : '发送请求' }}
</a-button>
</div>
</div>
<!-- 结果展示区 - 始终显示 -->
<div class="section result-section">
<div class="section-header">
<div class="section-title">调试结果</div>
<div v-if="debugResult" class="section-actions">
<a-button size="small" @click="copyToClipboard(JSON.stringify(debugResult.response, null, 2))">
<template #icon><CopyOutlined /></template>复制
</a-button>
<a-button size="small" @click="debugResult = null">
<template #icon><DeleteOutlined /></template>清除
</a-button>
</div>
</div>
<!-- 有结果时显示 -->
<template v-if="debugResult">
<!-- 状态指示 -->
<div class="result-status" :class="debugResult.success ? 'success' : 'error'">
<span class="status-icon">
<CheckCircleOutlined v-if="debugResult.success" />
<CloseCircleOutlined v-else />
</span>
<span class="status-text">{{ debugResult.success ? '请求成功' : '请求失败' }}</span>
<span v-if="debugResult.duration" class="status-duration">耗时 {{ debugResult.duration }}</span>
</div>
<!-- 提取的结果数据 -->
<div v-if="debugResult.success && extractedResult" class="extracted-result">
<div class="extracted-label">提取结果</div>
<div class="extracted-content">{{ extractedResult }}</div>
</div>
<!-- 结果标签页 -->
<a-tabs v-model:activeKey="activeTab" class="result-tabs">
<a-tab-pane key="response" tab="响应数据">
<div class="code-block"><pre>{{ JSON.stringify(debugResult.response, null, 2) }}</pre></div>
</a-tab-pane>
<a-tab-pane key="request" tab="请求详情">
<div class="request-info">
<div class="info-row">
<span class="info-label">URL</span>
<span class="info-value url">{{ debugResult.request?.url }}</span>
</div>
<div class="info-row">
<span class="info-label">Method</span>
<span class="info-value method">{{ debugResult.request?.method }}</span>
</div>
<div class="info-row">
<span class="info-label">Headers</span>
<div class="code-block small"><pre>{{ JSON.stringify(debugResult.request?.headers, null, 2) }}</pre></div>
</div>
<div class="info-row">
<span class="info-label">Body</span>
<div class="code-block small"><pre>{{ JSON.stringify(debugResult.request?.body, null, 2) }}</pre></div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="raw" tab="原始响应">
<div class="code-block"><pre>{{ debugResult.rawResponse }}</pre></div>
</a-tab-pane>
<a-tab-pane v-if="!debugResult.success" key="error" tab="错误信息">
<div class="error-info">
<div class="error-message">{{ debugResult.error }}</div>
<div v-if="debugResult.stackTrace" class="code-block"><pre>{{ debugResult.stackTrace }}</pre></div>
</div>
</a-tab-pane>
</a-tabs>
</template>
<!-- 无结果时显示空状态 -->
<div v-else class="empty-result">
<div class="empty-icon"><ExperimentOutlined /></div>
<p>暂无调试结果</p>
<span class="empty-hint">配置参数后点击发送请求</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowLeftOutlined, ClearOutlined, SendOutlined, PlusOutlined, DeleteOutlined, CopyOutlined, ExperimentOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { getAiModels, debugAiModel, getAiModelTypes } from '@/api/ai'
import { uploadImage } from '@/api/upload'
const route = useRoute()
const selectedModelId = ref(null)
const selectedModel = ref(null)
const models = ref([])
const modelTypes = ref([])
const formData = reactive({})
const debugging = ref(false)
const debugResult = ref(null)
const activeTab = ref('response')
// 按厂商分组模型
const groupedModels = computed(() => {
const groups = {}
models.value.forEach(model => {
const providerName = model.providerName || '未知厂商'
if (!groups[providerName]) {
groups[providerName] = { name: providerName, models: [] }
}
groups[providerName].models.push(model)
})
return Object.values(groups)
})
// 解析输入参数配置
const inputParams = computed(() => {
if (!selectedModel.value || !selectedModel.value.inputParams) return []
try {
return JSON.parse(selectedModel.value.inputParams)
} catch (e) {
return []
}
})
// 检查是否可以提交
const canSubmit = computed(() => {
if (!selectedModel.value) return false
const missingParams = inputParams.value.filter(p => p.required && !formData[p.name])
return missingParams.length === 0
})
// 提取的结果数据
const extractedResult = computed(() => {
if (!debugResult.value || !debugResult.value.success || !debugResult.value.response) return null
if (!selectedModel.value || !selectedModel.value.responseMapping) return null
try {
const mapping = JSON.parse(selectedModel.value.responseMapping)
const resultPath = mapping.result
if (!resultPath) return null
// 解析路径提取数据,如 "data" 或 "choices[0].message.content"
const response = debugResult.value.response
return extractByPath(response, resultPath)
} catch (e) {
return null
}
})
// 根据路径提取数据
const extractByPath = (obj, path) => {
if (!obj || !path) return null
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.')
let result = obj
for (const part of parts) {
if (result === null || result === undefined) return null
result = result[part]
}
return typeof result === 'object' ? JSON.stringify(result, null, 2) : result
}
const getTypeLabel = (typeValue) => {
const type = modelTypes.value.find(t => t.value === typeValue)
return type ? type.label : typeValue
}
const loadModels = async () => {
try {
const res = await getAiModels({ status: 1 })
models.value = res.records || []
} catch (e) {
message.error('获取模型列表失败')
}
}
const loadModelTypes = async () => {
try {
const res = await getAiModelTypes()
modelTypes.value = res || []
} catch (e) {}
}
const filterOption = (input, option) => {
const text = option.children?.[0]?.children || ''
return text.toLowerCase().includes(input.toLowerCase())
}
const handleModelChange = (modelId) => {
selectedModel.value = models.value.find(m => m.id === modelId)
Object.keys(formData).forEach(key => delete formData[key])
debugResult.value = null
inputParams.value.forEach(param => {
if (param.default) formData[param.name] = param.default
else if (param.type === 'images') formData[param.name] = []
})
}
const getImageFileList = (paramName) => {
const url = formData[paramName]
if (!url) return []
return [{ uid: '-1', name: 'image.png', status: 'done', url }]
}
const getImagesFileList = (paramName) => {
const urls = formData[paramName] || []
return urls.map((url, index) => ({ uid: `-${index}`, name: `image-${index}.png`, status: 'done', url }))
}
const handleBeforeUpload = async (file, paramName, isMultiple) => {
if (!file.type.startsWith('image/')) { message.error('只能上传图片'); return false }
if (file.size / 1024 / 1024 > 10) { message.error('图片不能超过10MB'); return false }
try {
message.loading({ content: '上传中...', key: 'upload' })
const url = await uploadImage(file)
if (isMultiple) {
if (!formData[paramName]) formData[paramName] = []
formData[paramName].push(url)
} else {
formData[paramName] = url
}
message.success({ content: '上传成功', key: 'upload' })
} catch (e) {
message.error({ content: '上传失败', key: 'upload' })
}
return false
}
const handleRemoveImage = (paramName) => { delete formData[paramName] }
const handleRemoveImages = (paramName, file) => {
const urls = formData[paramName] || []
const index = urls.findIndex(url => url === file.url)
if (index > -1) urls.splice(index, 1)
}
const handleDebug = async () => {
const missingParams = inputParams.value.filter(p => p.required && !formData[p.name]).map(p => p.label)
if (missingParams.length > 0) { message.warning(`请填写: ${missingParams.join(', ')}`); return }
debugging.value = true
debugResult.value = null
try {
const result = await debugAiModel(selectedModelId.value, formData)
debugResult.value = result
activeTab.value = result.success ? 'response' : 'error'
message[result.success ? 'success' : 'error'](result.success ? '请求成功' : '请求失败')
} catch (e) {
message.error('请求失败: ' + e.message)
debugResult.value = { success: false, error: e.message, stackTrace: e.toString() }
} finally {
debugging.value = false
}
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => message.success('已复制')).catch(() => message.error('复制失败'))
}
const handleClearAll = () => {
selectedModelId.value = null
selectedModel.value = null
Object.keys(formData).forEach(key => delete formData[key])
debugResult.value = null
}
onMounted(async () => {
await Promise.all([loadModels(), loadModelTypes()])
const modelIdFromQuery = route.query.modelId
if (modelIdFromQuery) {
const modelId = parseInt(modelIdFromQuery)
if (models.value.find(m => m.id === modelId)) {
selectedModelId.value = modelId
handleModelChange(modelId)
}
}
})
</script>
<style scoped>
.debug-page {
min-height: 100vh;
background: #f7f8fa;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
color: #8c8c8c;
font-size: 14px;
}
.main-content {
padding: 24px;
}
.section {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
border: 1px solid #e8e8e8;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-actions {
display: flex;
gap: 8px;
}
.model-selector {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-code {
margin-left: 8px;
color: #8c8c8c;
font-size: 12px;
}
.model-meta {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fafafa;
border-radius: 6px;
font-size: 13px;
}
.meta-item {
display: flex;
gap: 6px;
}
.meta-label {
color: #8c8c8c;
}
.meta-value {
color: #1a1a1a;
font-weight: 500;
}
.meta-divider {
color: #d9d9d9;
}
.params-form {
margin-bottom: 20px;
}
.param-label {
font-weight: 500;
color: #262626;
}
.empty-params {
padding: 40px 0;
text-align: center;
}
.empty-text {
color: #bfbfbf;
font-size: 14px;
}
.image-upload-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #8c8c8c;
font-size: 12px;
}
.upload-hint {
font-size: 12px;
color: #bfbfbf;
}
.submit-area {
display: flex;
justify-content: center;
padding-top: 8px;
}
.result-status {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.result-status.success {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.result-status.error {
background: #fff2f0;
border: 1px solid #ffccc7;
}
.status-icon {
font-size: 18px;
}
.result-status.success .status-icon {
color: #52c41a;
}
.result-status.error .status-icon {
color: #ff4d4f;
}
.status-text {
font-weight: 500;
color: #262626;
}
.status-duration {
margin-left: auto;
color: #8c8c8c;
font-size: 13px;
}
/* 提取结果样式 */
.extracted-result {
background: #f0f9eb;
border: 1px solid #c2e7b0;
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
}
.extracted-label {
font-size: 12px;
font-weight: 600;
color: #67c23a;
margin-bottom: 8px;
text-transform: uppercase;
}
.extracted-content {
font-size: 14px;
color: #262626;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.empty-result {
text-align: center;
padding: 48px 0;
color: #bfbfbf;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-hint {
font-size: 12px;
color: #d9d9d9;
}
.code-block {
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 16px;
overflow: auto;
max-height: 400px;
}
.code-block.small {
max-height: 200px;
padding: 12px;
}
.code-block pre {
margin: 0;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: #262626;
white-space: pre-wrap;
word-break: break-all;
}
.request-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-label {
font-size: 12px;
font-weight: 600;
color: #8c8c8c;
text-transform: uppercase;
}
.info-value {
font-size: 14px;
color: #262626;
}
.info-value.url {
font-family: 'SF Mono', monospace;
font-size: 13px;
word-break: break-all;
}
.info-value.method {
display: inline-block;
padding: 2px 8px;
background: #f0f0f0;
border-radius: 4px;
font-weight: 500;
}
.error-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.error-message {
padding: 12px 16px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
color: #cf1322;
}
:deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
:deep(.ant-upload-list-picture-card-container),
:deep(.ant-upload-select-picture-card) {
width: 80px;
height: 80px;
}
:deep(.ant-form-item) {
margin-bottom: 16px;
}
:deep(.ant-input),
:deep(.ant-select-selector),
:deep(.ant-input-number) {
border-radius: 6px;
}
:deep(.ant-input:focus),
:deep(.ant-select-focused .ant-select-selector) {
border-color: #595959;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
}
:deep(.ant-btn-primary) {
background: #262626;
border-color: #262626;
border-radius: 6px;
height: 40px;
padding: 0 32px;
}
:deep(.ant-btn-primary:hover) {
background: #434343;
border-color: #434343;
}
:deep(.ant-btn-primary:disabled) {
background: #d9d9d9;
border-color: #d9d9d9;
}
.code-block::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.code-block::-webkit-scrollbar-track {
background: #f0f0f0;
}
.code-block::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
</style>