Add Luxsin curve API client and validation functionality

- Introduced a new module `curve_client.py` for interacting with the Luxsin curve API, including functions for custom Base64 decoding and parametric EQ validation.
- Implemented error handling for API responses and validation checks for curve data.
- Removed outdated test files to streamline the codebase.
- Updated brand creation and update logic in `brands.py` to ensure brand names are validated and checked for duplicates.
- Enhanced model handling in `models.py` with new validation and push-to-search functionality, integrating curve validation before pushing to Meilisearch.
- Updated frontend components to support new validation and push processes, including user feedback for validation results and progress tracking.
- Changed favicon to SVG format for better scalability and appearance.
This commit is contained in:
yangy
2026-05-20 14:13:42 +08:00
parent 1564c8766d
commit fb1aad45af
12 changed files with 653 additions and 256 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="0.88em" x="50" text-anchor="middle" font-size="72"><§</text>
</svg>

After

Width:  |  Height:  |  Size: 142 B

+18 -2
View File
@@ -72,13 +72,29 @@ export function deleteModel(id) {
}
/**
* 推送到 Meilisearch
* 推送前校验曲线数据
* @param {Array<number>} modelIds - 型号 ID 列表
*/
export function validatePushToMeilisearch(modelIds) {
return request({
url: '/models/push-to-search/validate',
method: 'post',
data: { model_ids: modelIds },
timeout: 120000,
skipErrorToast: true
})
}
/**
* 推送到 Meilisearch(需先通过校验)
* @param {Array<number>} modelIds - 型号 ID 列表
*/
export function pushToMeilisearch(modelIds) {
return request({
url: '/models/push-to-search',
method: 'post',
data: { model_ids: modelIds }
data: { model_ids: modelIds },
timeout: 60000,
skipErrorToast: true
})
}
+7 -3
View File
@@ -29,10 +29,14 @@ request.interceptors.response.use(
response => {
const res = response.data
// 如果响应码为 0,表示错误
// 如果响应码为 0,表示错误(可由调用方自行提示,如推送进度弹窗)
if (res.code === 0) {
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || '请求失败'))
if (!response.config.skipErrorToast) {
ElMessage.error(res.msg || '请求失败')
}
const err = new Error(res.msg || '请求失败')
err.responseData = res
return Promise.reject(err)
}
return res
+322 -28
View File
@@ -45,9 +45,8 @@
<div class="header-buttons">
<el-button
type="warning"
:disabled="selectedIds.length === 0"
:disabled="selectedIds.length === 0 || pushProgressRunning"
@click="handlePushToSearch"
:loading="pushLoading"
>
<el-icon><Upload /></el-icon>
推送到搜索 ({{ selectedIds.length }})
@@ -82,7 +81,7 @@
<span v-else>{{ scope.row.form || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="rig" label="阻抗" width="120" />
<el-table-column prop="rig" label="阻抗" width="150" />
<el-table-column prop="source" label="来源" width="150" show-overflow-tooltip />
<el-table-column prop="create_at" label="创建时间" width="180" sortable="custom" />
<el-table-column label="操作" width="200" fixed="right">
@@ -208,19 +207,121 @@
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="pushProgressVisible"
title="推送到搜索"
width="480px"
:close-on-click-modal="false"
:close-on-press-escape="!pushProgressRunning"
:show-close="!pushProgressRunning"
@closed="resetPushProgress"
>
<ul class="push-progress-steps">
<li class="push-progress-step push-progress-step--validate">
<div class="step-main">
<span class="step-label">校验数据</span>
<p
v-if="pushSteps.validate === 'loading' && validatingCurrent"
class="step-detail"
>
正在校验{{ validatingCurrent.brand_name }} · {{ validatingCurrent.name }}
<span v-if="validateProgress.total > 1" class="step-progress-text">
{{ validateProgress.index }}/{{ validateProgress.total }}
</span>
</p>
</div>
<span class="step-status">
<el-icon v-if="pushSteps.validate === 'loading'" class="is-loading step-icon-loading">
<Loading />
</el-icon>
<el-icon v-else-if="pushSteps.validate === 'success'" class="step-icon-success">
<CircleCheck />
</el-icon>
<el-icon v-else-if="pushSteps.validate === 'error'" class="step-icon-error">
<CircleClose />
</el-icon>
</span>
</li>
<li class="push-progress-step push-progress-step--push">
<div class="step-main">
<span class="step-label">推送</span>
<p v-if="pushSteps.push === 'loading' && pushingCurrent" class="step-detail">
正在推送{{ pushingCurrent.brand_name }} · {{ pushingCurrent.name }}
</p>
<p v-else-if="pushSteps.push === 'success' && pushResultCount > 0" class="step-detail step-detail--muted">
已推送 {{ pushResultCount }}
</p>
</div>
<span class="step-status">
<el-icon v-if="pushSteps.push === 'loading'" class="is-loading step-icon-loading">
<Loading />
</el-icon>
<el-icon v-else-if="pushSteps.push === 'success'" class="step-icon-success">
<CircleCheck />
</el-icon>
<el-icon v-else-if="pushSteps.push === 'error'" class="step-icon-error">
<CircleClose />
</el-icon>
</span>
</li>
</ul>
<div v-if="pushValidateErrors.length > 0" class="push-validate-errors">
<p class="push-validate-errors-title">校验未通过</p>
<ul>
<li v-for="item in pushValidateErrors" :key="item.id">
{{ item.brand_name }} {{ item.name }}{{ formatValidateReason(item.reason) }}
</li>
</ul>
</div>
<template #footer>
<el-button :disabled="pushProgressRunning" type="primary" @click="pushProgressVisible = false">
{{ pushProgressRunning ? '处理中' : '关闭' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Upload, UploadFilled } from '@element-plus/icons-vue'
import { getModels, createModel, updateModel, deleteModel, pushToMeilisearch } from '@/api/model'
import {
Search,
Refresh,
Plus,
Upload,
UploadFilled,
Loading,
CircleCheck,
CircleClose
} from '@element-plus/icons-vue'
import {
getModels,
createModel,
updateModel,
deleteModel,
validatePushToMeilisearch,
pushToMeilisearch
} from '@/api/model'
import { getBrands, createBrand } from '@/api/brand'
const loading = ref(false)
const submitLoading = ref(false)
const pushLoading = ref(false)
const pushProgressVisible = ref(false)
const pushSteps = reactive({
validate: 'wait',
push: 'wait'
})
const pushValidateErrors = ref([])
const validatingCurrent = ref(null)
const pushingCurrent = ref(null)
const pushResultCount = ref(0)
const validateProgress = reactive({ index: 0, total: 0 })
const selectedRows = ref([])
const pushProgressRunning = computed(
() => pushSteps.validate === 'loading' || pushSteps.push === 'loading'
)
const dialogVisible = ref(false)
const dialogTitle = ref('新型号')
const formRef = ref(null)
@@ -579,7 +680,104 @@ const handlePageChange = () => {
// 处理选择变化
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(row => row.id)
selectedRows.value = selection
selectedIds.value = selection.map((row) => row.id)
}
/** 校验失败原因展示:曲线类错误仅显示「曲线数据异常」 */
function formatValidateReason(reason) {
if (!reason) return '校验失败'
if (reason.includes('曲线数据异常')) return '曲线数据异常'
return reason
}
function resetPushProgress() {
pushSteps.validate = 'wait'
pushSteps.push = 'wait'
pushValidateErrors.value = []
validatingCurrent.value = null
pushingCurrent.value = null
pushResultCount.value = 0
validateProgress.index = 0
validateProgress.total = 0
}
async function runPushToSearch(rows) {
pushProgressVisible.value = true
resetPushProgress()
pushSteps.validate = 'loading'
pushSteps.push = 'wait'
validateProgress.total = rows.length
const errors = []
let pushedCount = 0
let validationFailCount = 0
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
validateProgress.index = i + 1
validatingCurrent.value = {
brand_name: row.brand_name,
name: row.name
}
try {
await validatePushToMeilisearch([row.id])
} catch (error) {
validationFailCount += 1
const fromApi = error.responseData?.data?.errors
if (Array.isArray(fromApi) && fromApi.length > 0) {
errors.push(...fromApi)
} else {
errors.push({
id: row.id,
brand_name: row.brand_name,
name: row.name,
reason: error.message || '校验失败'
})
}
continue
}
pushSteps.push = 'loading'
pushingCurrent.value = {
brand_name: row.brand_name,
name: row.name
}
try {
const res = await pushToMeilisearch([row.id])
pushedCount += res.data?.pushed_count ?? 1
} catch (error) {
errors.push({
id: row.id,
brand_name: row.brand_name,
name: row.name,
reason: error.message || '推送失败'
})
}
}
validatingCurrent.value = null
pushingCurrent.value = null
pushResultCount.value = pushedCount
pushSteps.validate = validationFailCount === rows.length ? 'error' : 'success'
if (errors.length > 0) {
pushValidateErrors.value = errors
}
if (pushedCount > 0) {
pushSteps.push = 'success'
if (errors.length > 0) {
ElMessage.warning(`成功推送 ${pushedCount} 条,${errors.length} 条未通过校验或推送失败`)
} else {
ElMessage.success(`成功推送 ${pushedCount} 条数据到搜索引擎`)
}
} else if (errors.some((e) => (e.reason || '').includes('推送'))) {
pushSteps.push = 'error'
} else {
pushSteps.push = 'wait'
}
}
// 推送到 Meilisearch
@@ -588,33 +786,25 @@ const handlePushToSearch = () => {
ElMessage.warning('请选择要推送的型号')
return
}
const rows = selectedRows.value.length
? [...selectedRows.value]
: tableData.value.filter((row) => selectedIds.value.includes(row.id))
if (!rows.length) {
ElMessage.warning('请选择要推送的型号')
return
}
ElMessageBox.confirm(
`确定要将选中的 ${selectedIds.value.length} 个型号推送到搜索引擎吗?`,
`确定要将选中的 ${rows.length} 个型号推送到搜索引擎吗?`,
'推送确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
type: 'warning'
}
).then(async () => {
pushLoading.value = true
try {
const res = await pushToMeilisearch(selectedIds.value)
if (res.code === 1) {
ElMessage.success(`成功推送 ${res.data.pushed_count} 条数据到搜索引擎`)
console.log('推送的数据:', res.data.models)
} else {
ElMessage.error(res.msg || '推送失败')
}
} catch (error) {
console.error('推送失败:', error)
ElMessage.error('推送失败')
} finally {
pushLoading.value = false
}
}).catch(() => {})
)
.then(() => runPushToSearch(rows))
.catch(() => {})
}
onMounted(() => {
@@ -667,4 +857,108 @@ onMounted(() => {
line-height: 1.5;
color: var(--el-color-warning);
}
.push-progress-steps {
list-style: none;
margin: 0;
padding: 8px 4px 0;
}
.push-progress-step {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 12px;
border-radius: 10px;
background: rgba(148, 163, 184, 0.08);
margin-bottom: 10px;
}
.push-progress-step--validate,
.push-progress-step--push {
align-items: flex-start;
}
.push-progress-step--validate .step-status,
.push-progress-step--push .step-status {
margin-top: 2px;
}
.step-detail--muted {
color: #94a3b8;
}
.step-main {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.step-detail {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: #64748b;
word-break: break-all;
}
.step-progress-text {
color: #94a3b8;
}
.push-progress-step:last-child {
margin-bottom: 0;
}
.step-label {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.step-status {
display: flex;
align-items: center;
min-width: 24px;
justify-content: flex-end;
}
.step-icon-loading {
font-size: 20px;
color: var(--el-color-primary);
}
.step-icon-success {
font-size: 22px;
color: var(--el-color-success);
}
.step-icon-error {
font-size: 22px;
color: var(--el-color-danger);
}
.push-validate-errors {
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(245, 108, 108, 0.08);
max-height: 200px;
overflow-y: auto;
}
.push-validate-errors-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-color-danger);
}
.push-validate-errors ul {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.6;
color: #64748b;
}
</style>