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
+148
View File
@@ -0,0 +1,148 @@
"""Luxsin 曲线 API:拉取、自定义 Base64 解码与 parametric_eq 校验。"""
import base64
import json
import logging
from typing import Any, Optional, Tuple
from urllib.parse import urlencode
import requests
logger = logging.getLogger(__name__)
LUXSIN_CURVE_API_BASE = "https://api.luxsin.com.cn/audio/getCurve"
CUSTOM_CHARS = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/"
STANDARD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
TARGET_OVER_EAR = "Harman over-ear 2018"
TARGET_IN_EAR = "Harman in-ear 2019"
FORM_TARGET_MAP = {
"over-ear": TARGET_OVER_EAR,
"in-ear": TARGET_IN_EAR,
}
def curve_target_for_form(form: Optional[str]) -> Optional[str]:
if not form:
return None
return FORM_TARGET_MAP.get(form.strip().lower())
def custom_base64_to_string(encoded: str) -> str:
"""将 Luxsin 自定义 Base64 字母表映射为标准 Base64 后解码为 UTF-8 字符串。"""
if not encoded or not isinstance(encoded, str):
raise ValueError("曲线数据为空")
standard_b64 = []
for c in encoded:
idx = CUSTOM_CHARS.find(c)
if idx != -1:
standard_b64.append(STANDARD_CHARS[idx])
else:
standard_b64.append(c)
try:
raw = base64.b64decode("".join(standard_b64), validate=False)
except Exception as e:
raise ValueError(f"Base64 解码失败:{e}") from e
try:
return raw.decode("utf-8")
except UnicodeDecodeError as e:
raise ValueError(f"UTF-8 解码失败:{e}") from e
def is_valid_parametric_eq_payload(data: Any) -> bool:
if not isinstance(data, dict):
return False
peq = data.get("parametric_eq")
if not isinstance(peq, dict):
return False
filters = peq.get("filters")
return isinstance(filters, list) and len(filters) == 10
def extract_encoded_payload(response: requests.Response) -> str:
"""从 getCurve 响应中提取待解码字符串。"""
text = (response.text or "").strip()
if not text:
raise ValueError("曲线接口响应为空")
try:
body = response.json()
except ValueError:
return text
if isinstance(body, str):
return body.strip()
if not isinstance(body, dict):
raise ValueError("曲线接口响应格式异常")
for key in ("data", "curve", "result", "content", "body"):
val = body.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
nested = body.get("data")
if isinstance(nested, dict):
for key in ("curve", "data", "content", "encoded"):
val = nested.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(nested, str) and nested.strip():
return nested.strip()
raise ValueError("曲线接口响应中未找到可解码数据")
def fetch_and_validate_curve(
brand: str,
name: str,
form: str,
*,
timeout: float = 20.0,
) -> Tuple[bool, str]:
"""
拉取并校验曲线。返回 (是否通过, 失败原因);通过时原因为空字符串。
"""
brand = (brand or "").strip()
name = (name or "").strip()
if not brand or not name:
return False, "品牌名称或型号名称为空"
target = curve_target_for_form(form)
if not target:
return False, "佩戴方式须为入耳式(in-ear)或头戴式(over-ear)才能校验曲线"
query = urlencode({"brand": brand, "name": name, "target": target})
url = f"{LUXSIN_CURVE_API_BASE}?{query}"
try:
resp = requests.get(url, timeout=timeout)
except requests.RequestException as e:
logger.warning("getCurve request failed: %s %s", url, e)
return False, f"无法连接曲线接口:{e}"
if resp.status_code != 200:
return False, f"曲线接口返回 HTTP {resp.status_code}"
try:
encoded = extract_encoded_payload(resp)
except ValueError as e:
return False, str(e)
try:
decoded_text = custom_base64_to_string(encoded)
payload = json.loads(decoded_text)
except json.JSONDecodeError:
return False, "解码后的数据不是合法 JSON"
except ValueError as e:
return False, str(e)
if not is_valid_parametric_eq_payload(payload):
return False, "曲线数据异常"
return True, ""
+27 -11
View File
@@ -65,14 +65,16 @@ def get_brand(brand_id: int, db: Session = Depends(get_db)):
def create_brand(brand: BrandCreate, db: Session = Depends(get_db)):
"""创建新品牌"""
try:
logger.info(f"Creating brand: name={brand.name}")
# 检查是否已存在
existing = db.query(Brand).filter(Brand.name == brand.name).first()
name = (brand.name or "").strip()
if not name:
return ApiResponse.error(msg="品牌名称不能为空", code=0)
logger.info(f"Creating brand: name={name}")
existing = db.query(Brand).filter(Brand.name == name).first()
if existing:
logger.warning(f"Brand already exists: name={brand.name}")
logger.warning(f"Brand already exists: name={name}")
return ApiResponse.error(msg="品牌名称已存在", code=0)
db_brand = Brand(name=brand.name)
db_brand = Brand(name=name)
db.add(db_brand)
db.commit()
db.refresh(db_brand)
@@ -88,19 +90,33 @@ def create_brand(brand: BrandCreate, db: Session = Depends(get_db)):
def update_brand(brand_id: int, brand: BrandUpdate, db: Session = Depends(get_db)):
"""更新品牌"""
try:
logger.info(f"Updating brand: id={brand_id}, data={brand.dict()}")
logger.info(f"Updating brand: id={brand_id}, data={brand.model_dump()}")
db_brand = db.query(Brand).filter(Brand.id == brand_id).first()
if not db_brand:
logger.warning(f"Brand not found: id={brand_id}")
return ApiResponse.no_data(msg="品牌不存在")
# 如果更新名称,检查是否冲突
if brand.name and brand.name != db_brand.name:
existing = db.query(Brand).filter(Brand.name == brand.name).first()
if brand.name is None:
db.commit()
db.refresh(db_brand)
return ApiResponse.success(data=db_brand.to_dict(), msg="品牌更新成功")
new_name = brand.name.strip()
if not new_name:
return ApiResponse.error(msg="品牌名称不能为空", code=0)
current_name = (db_brand.name or "").strip()
if new_name != current_name:
# 排除当前记录;MySQL 大小写不敏感时,仅改大小写也会命中自身
existing = (
db.query(Brand)
.filter(Brand.name == new_name, Brand.id != brand_id)
.first()
)
if existing:
logger.warning(f"Brand name already exists: name={brand.name}")
logger.warning(f"Brand name already exists: name={new_name}")
return ApiResponse.error(msg="品牌名称已存在", code=0)
db_brand.name = brand.name
db_brand.name = new_name
db.commit()
db.refresh(db_brand)
+121 -59
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc
from typing import List, Optional
@@ -6,6 +7,7 @@ import logging
from database import get_db
from models.model import Model
from response import ApiResponse, PageData
from curve_client import fetch_and_validate_curve
import requests
import os
import shutil
@@ -16,6 +18,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/models", tags=["models"])
class PushToSearchBody(BaseModel):
"""推送 Meilisearch 请求体;字段名 model_ids 需关闭 protected_namespaces 避免 Pydantic 警告。"""
model_config = ConfigDict(protected_namespaces=())
model_ids: List[int] = Field(..., min_length=1, description="型号 ID 列表")
# Meilisearch 配置(从环境变量读取)
MEILISEARCH_URL = os.getenv("MEILISEARCH_URL", "http://localhost:7700")
MEILISEARCH_API_KEY = os.getenv("MEILISEARCH_API_KEY", "")
@@ -260,69 +271,120 @@ def delete_model(model_id: int, db: Session = Depends(get_db)):
return ApiResponse(code=0, msg="error", data=None)
@router.post("/push-to-search", response_model=ApiResponse)
def push_to_search(
model_ids: List[int] = Body(..., embed=True, description="型号 ID 列表"),
db: Session = Depends(get_db)
):
"""推送型号数据到 Meilisearch"""
try:
if not model_ids:
return ApiResponse(code=0, msg="请选择要推送的型号", data=None)
def _get_models_by_ids(model_ids: List[int], db: Session):
if not model_ids:
return None, ApiResponse(code=0, msg="请选择要推送的型号", data=None)
models = db.query(Model).filter(Model.id.in_(model_ids)).all()
if not models:
return None, ApiResponse(code=0, msg="未找到选中的型号数据", data=None)
return models, None
# 查询选中的型号
models = db.query(Model).filter(Model.id.in_(model_ids)).all()
if not models:
return ApiResponse(code=0, msg="未找到选中的型号数据", data=None)
# 准备推送数据
push_data = []
for model in models:
model_dict = {
'id': model.id,
'brand_name': model.brand_name,
'name': model.name,
'rig': model.rig,
'form': model.form,
'source': model.source
}
# 只包含有值的字段
push_data.append({k: v for k, v in model_dict.items() if v is not None})
# 推送到 Meilisearch
headers = {
"Authorization": f"Bearer {MEILISEARCH_API_KEY}",
"Content-Type": "application/json"
}
# 如果索引不存在,先创建索引
try:
# 更新或添加文档
response = requests.post(
f"{MEILISEARCH_URL}/indexes/{MEILISEARCH_INDEX}/documents",
json=push_data,
headers=headers,
timeout=30
)
if response.status_code not in [200, 202]:
return ApiResponse(code=0, msg=f"推送到 Meilisearch 失败:{response.text}", data=None)
task_info = response.json()
return ApiResponse(
code=1,
msg="success",
data={
"pushed_count": len(push_data),
"task_uid": task_info.get("taskUid"),
"models": push_data
def _validate_models_curve(models) -> List[dict]:
validation_errors = []
for model in models:
ok, reason = fetch_and_validate_curve(
model.brand_name,
model.name,
model.form or "",
)
if not ok:
validation_errors.append(
{
"id": model.id,
"brand_name": model.brand_name,
"name": model.name,
"form": model.form,
"reason": reason,
}
)
return validation_errors
except requests.exceptions.RequestException as e:
return ApiResponse(code=0, msg=f"连接 Meilisearch 失败:{str(e)}", data=None)
def _build_meilisearch_documents(models) -> List[dict]:
push_data = []
for model in models:
model_dict = {
"id": model.id,
"brand_name": model.brand_name,
"name": model.name,
"rig": model.rig,
"form": model.form,
"source": model.source,
}
push_data.append({k: v for k, v in model_dict.items() if v is not None})
return push_data
def _push_documents_to_meilisearch(push_data: List[dict]) -> ApiResponse:
headers = {
"Authorization": f"Bearer {MEILISEARCH_API_KEY}",
"Content-Type": "application/json",
}
try:
response = requests.post(
f"{MEILISEARCH_URL}/indexes/{MEILISEARCH_INDEX}/documents",
json=push_data,
headers=headers,
timeout=30,
)
if response.status_code not in [200, 202]:
return ApiResponse(code=0, msg=f"推送到 Meilisearch 失败:{response.text}", data=None)
task_info = response.json()
return ApiResponse(
code=1,
msg="success",
data={
"pushed_count": len(push_data),
"task_uid": task_info.get("taskUid"),
"models": push_data,
},
)
except requests.exceptions.RequestException as e:
return ApiResponse(code=0, msg=f"连接 Meilisearch 失败:{str(e)}", data=None)
@router.post("/push-to-search/validate", response_model=ApiResponse)
def validate_push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)):
"""推送前校验:拉取并验证曲线 parametric_eq 数据"""
try:
models, err = _get_models_by_ids(body.model_ids, db)
if err:
return err
validation_errors = _validate_models_curve(models)
if validation_errors:
names = "".join(
f"{e['brand_name']} {e['name']}" for e in validation_errors[:5]
)
suffix = "" if len(validation_errors) > 5 else ""
return ApiResponse(
code=0,
msg=f"曲线数据校验未通过:{names}{suffix}",
data={"errors": validation_errors, "validated_count": 0},
)
return ApiResponse(
code=1,
msg="success",
data={"validated_count": len(models)},
)
except Exception as e:
return ApiResponse(code=0, msg="error", data=None)
logger.error("validate_push_to_search failed: %s", e, exc_info=True)
return ApiResponse(code=0, msg=f"校验失败:{str(e)}", data=None)
@router.post("/push-to-search", response_model=ApiResponse)
def push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)):
"""推送型号数据到 Meilisearch(需先通过 validate 接口)"""
try:
models, err = _get_models_by_ids(body.model_ids, db)
if err:
return err
push_data = _build_meilisearch_documents(models)
return _push_documents_to_meilisearch(push_data)
except Exception as e:
logger.error("push_to_search failed: %s", e, exc_info=True)
return ApiResponse(code=0, msg=f"推送失败:{str(e)}", data=None)
-12
View File
@@ -1,12 +0,0 @@
from database import get_db
from models import Brand
if __name__ == "__main__":
db_gen = get_db()
db = next(db_gen)
try:
brands = db.query(Brand).all()
for brand in brands:
print(brand.name)
finally:
db_gen.close() # 这会触发 finally 块中的 db.close()
-17
View File
@@ -1,17 +0,0 @@
import requests
import json
try:
response = requests.get("http://localhost:8001/api/brands/", params={"skip": 0, "limit": 10})
print(f"Status Code: {response.status_code}")
print(f"Content-Type: {response.headers.get('content-type')}")
print(f"Response: {response.text[:500]}")
if response.status_code == 200:
data = response.json()
print(f"\nSuccess! Got {len(data)} brands")
for brand in data[:5]:
print(f" - {brand}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
-52
View File
@@ -1,52 +0,0 @@
import requests
import json
BASE_URL = "http://localhost:8002"
def test_api(endpoint, method="GET", data=None, params=None):
"""测试 API 接口"""
url = f"{BASE_URL}{endpoint}"
try:
if method == "GET":
response = requests.get(url, params=params)
elif method == "POST":
response = requests.post(url, json=data, params=params)
elif method == "PUT":
response = requests.put(url, json=data)
elif method == "DELETE":
response = requests.delete(url)
result = response.json()
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
print(f"Response: {json.dumps(result, ensure_ascii=False, indent=2)}")
return result
except Exception as e:
print(f"\n{method} {endpoint} - Error: {e}")
import traceback
traceback.print_exc()
return None
# 测试品牌接口
print("=" * 60)
print("测试品牌接口")
print("=" * 60)
# 1. 获取品牌列表(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 5})
# 2. 模糊查询品牌(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "Audio"})
# 3. 模糊查询品牌(无数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "不存在的品牌 XYZ"})
# 4. 获取单个品牌(成功)
test_api("/api/brands/1")
# 5. 获取单个品牌(不存在)
test_api("/api/brands/999999")
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
-65
View File
@@ -1,65 +0,0 @@
import requests
import json
BASE_URL = "http://localhost:8002"
def test_api(endpoint, method="GET", data=None, params=None):
"""测试 API 接口"""
url = f"{BASE_URL}{endpoint}"
try:
if method == "GET":
response = requests.get(url, params=params)
elif method == "POST":
response = requests.post(url, json=data, params=params)
elif method == "PUT":
response = requests.put(url, json=data)
elif method == "DELETE":
response = requests.delete(url)
result = response.json()
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
print(f"Response: {json.dumps(result, ensure_ascii=False, indent=2)}")
return result
except Exception as e:
print(f"\n{method} {endpoint} - Error: {e}")
return None
# 测试品牌接口
print("=" * 60)
print("测试品牌接口")
print("=" * 60)
# 1. 获取品牌列表(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 5})
# 2. 模糊查询品牌(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "Audio"})
# 3. 模糊查询品牌(无数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "不存在的品牌XYZ"})
# 4. 获取单个品牌(成功)
test_api("/api/brands/1")
# 5. 获取单个品牌(不存在)
test_api("/api/brands/999999")
# 6. 创建品牌(成功)
test_api("/api/brands/", method="POST", data={"name": "Test Brand 测试"})
# 7. 创建品牌(重复)
test_api("/api/brands/", method="POST", data={"name": "1MORE"})
# 8. 更新品牌(成功)
test_api("/api/brands/1", method="PUT", data={"name": "1MORE Updated"})
# 9. 删除品牌(成功)
test_api("/api/brands/623", method="DELETE")
# 10. 删除品牌(不存在)
test_api("/api/brands/999999", method="DELETE")
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
+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
+321 -27
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
@@ -589,32 +787,24 @@ const handlePushToSearch = () => {
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>