modex/index.vue 分割

This commit is contained in:
eafonyang
2026-07-01 16:53:38 +08:00
parent cacee5f294
commit e2577ff661
10 changed files with 1999 additions and 1157 deletions
+14
View File
@@ -148,3 +148,17 @@ export function pushToMeilisearch(modelIds) {
skipErrorToast: true
})
}
/**
* 从 squig.link share URL 抓取频响数据
* @param {string} shareUrl - squig.link 分享链接
* @param {string} [selectedFile] - 用户从候选列表选择的文件名
*/
export function fetchFromSquigLink(shareUrl, selectedFile) {
return request({
url: '/models/squiglink-fetch',
method: 'post',
data: { share_url: shareUrl, selected_file: selectedFile },
timeout: 30000
})
}
@@ -0,0 +1,49 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="720px"
class="csv-dialog"
@update:model-value="$emit('update:modelValue', $event)"
>
<p v-if="s3Key" class="csv-s3-key">{{ s3Key }}</p>
<el-input
:model-value="content"
type="textarea"
:rows="18"
readonly
placeholder="暂无内容"
class="csv-content"
/>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '频响 CSV' },
s3Key: { type: String, default: '' },
content: { type: String, default: '' }
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.csv-s3-key {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: #94a3b8;
word-break: break-all;
}
.csv-content :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,305 @@
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="720px"
class="eq-cache-dialog"
@update:model-value="$emit('update:modelValue', $event)"
@closed="resetEqCacheDialog"
>
<div v-if="eqCacheView === 'loading'" class="push-view-loading">
<el-icon class="is-loading"><Loading /></el-icon>
加载中
</div>
<div v-else-if="eqCacheView === 'empty'">
<el-empty description="暂无 EQ 缓存" :image-size="80" />
<p v-if="eqCacheRedisKey" class="csv-s3-key">Redis Key: {{ eqCacheRedisKey }}</p>
</div>
<div v-else-if="eqCacheView === 'list'" class="eq-cache-content">
<p class="csv-s3-key">Redis Key: {{ eqCacheRedisKey }}</p>
<p class="eq-cache-keys-title">Hash Keys{{ eqCacheFieldKeys.length }}</p>
<ul class="eq-cache-keys">
<li
v-for="key in eqCacheFieldKeys"
:key="key"
:class="{ 'is-active': eqCacheActiveKey === key }"
>
<button
type="button"
class="eq-cache-key-btn"
@click="handleEqFieldClick(key)"
>
<span class="eq-cache-key-text">{{ key }}</span>
<el-icon
v-if="eqCacheFieldLoadingKey === key"
class="is-loading eq-cache-key-icon"
>
<Loading />
</el-icon>
<el-icon v-else class="eq-cache-key-icon">
<ArrowDown />
</el-icon>
</button>
<div
v-if="eqCacheActiveKey === key && eqCacheFieldData !== null"
class="eq-cache-value"
>
<VueJsonPretty
:data="eqCacheFieldData"
:deep="2"
:show-length="true"
:show-line-number="false"
:show-icon="true"
:show-select-controller="false"
:editable="false"
/>
</div>
</li>
</ul>
</div>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading, ArrowDown } from '@element-plus/icons-vue'
import { getModelEqCache, getModelEqCacheField } from '@/api/model'
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
const props = defineProps({
modelValue: { type: Boolean, default: false },
modelId: { type: [Number, String], default: null },
modelLabel: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const dialogTitle = ref('查看 EQ 缓存')
/** idle | loading | empty | list */
const eqCacheView = ref('idle')
const eqCacheRedisKey = ref('')
const eqCacheFieldKeys = ref([])
const eqCacheActiveKey = ref('')
const eqCacheFieldLoadingKey = ref('')
const eqCacheFieldData = ref(null)
function resetEqCacheDialog() {
eqCacheView.value = 'idle'
eqCacheRedisKey.value = ''
eqCacheFieldKeys.value = []
eqCacheActiveKey.value = ''
eqCacheFieldLoadingKey.value = ''
eqCacheFieldData.value = null
dialogTitle.value = '查看 EQ 缓存'
}
function parseEqFieldValue(value) {
if (value === null || value === undefined) return null
if (typeof value === 'object') return value
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return { _raw: value }
}
}
return { value }
}
const handleEqFieldClick = async (key) => {
if (eqCacheActiveKey.value === key && !eqCacheFieldLoadingKey.value) {
eqCacheActiveKey.value = ''
eqCacheFieldData.value = null
return
}
eqCacheActiveKey.value = key
eqCacheFieldData.value = null
eqCacheFieldLoadingKey.value = key
try {
const res = await getModelEqCacheField(props.modelId, key)
if (res.code === 1 && res.data) {
eqCacheFieldData.value = parseEqFieldValue(res.data.value)
} else {
ElMessage.error(res.msg || '加载 hash value 失败')
eqCacheActiveKey.value = ''
}
} catch (error) {
console.error('加载 hash value 失败:', error)
ElMessage.error(error.message || '加载 hash value 失败')
eqCacheActiveKey.value = ''
} finally {
eqCacheFieldLoadingKey.value = ''
}
}
// 当对话框打开时自动加载
watch(() => props.modelValue, async (visible) => {
if (visible && props.modelId) {
dialogTitle.value = props.modelLabel || '查看 EQ 缓存'
eqCacheView.value = 'loading'
eqCacheRedisKey.value = ''
eqCacheFieldKeys.value = []
eqCacheActiveKey.value = ''
eqCacheFieldData.value = null
try {
const res = await getModelEqCache(props.modelId)
if (res.code !== 1) {
ElMessage.error(res.msg || '获取 EQ 缓存失败')
emit('update:modelValue', false)
return
}
const fieldKeys = Array.isArray(res.data?.field_keys) ? res.data.field_keys : []
eqCacheRedisKey.value = res.data?.redis_key || props.modelLabel
eqCacheFieldKeys.value = fieldKeys
eqCacheView.value = fieldKeys.length > 0 ? 'list' : 'empty'
} catch (error) {
console.error('获取 EQ 缓存失败:', error)
ElMessage.error(error.message || '获取 EQ 缓存失败')
emit('update:modelValue', false)
}
}
})
</script>
<style scoped>
.push-view-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 0;
color: #94a3b8;
}
.csv-s3-key {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: #94a3b8;
word-break: break-all;
}
.eq-cache-content {
padding: 4px 0;
}
.eq-cache-keys-title {
margin: 12px 0 8px;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
}
.eq-cache-keys {
margin: 0;
padding: 0;
list-style: none;
max-height: 360px;
overflow-y: auto;
}
.eq-cache-keys li {
margin-bottom: 8px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.08);
overflow: hidden;
}
.eq-cache-keys li.is-active {
background: rgba(148, 163, 184, 0.14);
}
.eq-cache-key-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
font-size: 13px;
line-height: 1.5;
color: #cbd5e1;
text-align: left;
cursor: pointer;
}
.eq-cache-key-btn:hover {
color: #e2e8f0;
}
.eq-cache-key-text {
flex: 1;
word-break: break-all;
}
.eq-cache-key-icon {
flex-shrink: 0;
font-size: 14px;
color: #94a3b8;
transition: transform 0.2s;
}
.eq-cache-keys li.is-active .eq-cache-key-icon {
transform: rotate(180deg);
}
.eq-cache-value {
padding: 0 12px 12px;
max-height: 420px;
overflow: auto;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.eq-cache-value :deep(.vjs-tree) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
color: #cbd5e1;
background: transparent;
}
.eq-cache-value :deep(.vjs-tree-node.is-highlight),
.eq-cache-value :deep(.vjs-tree-node:hover) {
background-color: rgba(51, 65, 85, 0.72);
border-radius: 4px;
}
.eq-cache-value :deep(.vjs-tree-node .vjs-tree-node-actions) {
background-color: rgba(30, 41, 59, 0.95);
}
.eq-cache-value :deep(.vjs-tree-brackets:hover),
.eq-cache-value :deep(.vjs-carets:hover) {
color: #7dd3fc;
}
.eq-cache-value :deep(.vjs-key) {
color: #7dd3fc;
}
.eq-cache-value :deep(.vjs-value-string) {
color: #86efac;
}
.eq-cache-value :deep(.vjs-value-number) {
color: #fcd34d;
}
.eq-cache-value :deep(.vjs-value-boolean) {
color: #f9a8d4;
}
.eq-cache-value :deep(.vjs-value-null) {
color: #94a3b8;
}
</style>
@@ -0,0 +1,554 @@
<template>
<!-- 新增/编辑对话框 -->
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="600px"
@update:model-value="$emit('update:modelValue', $event)"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<!-- squig.link 导入区 -->
<div class="squiglink-import-section">
<div class="squiglink-import-title">
<el-icon><Link /></el-icon>
<span> squig.link 导入</span>
</div>
<div class="squiglink-import-form">
<el-input
v-model="squigLinkUrl"
placeholder="粘贴 squig.link 分享链接,例如 https://theaudiostore.squig.link/?share=..."
clearable
@keyup.enter="handleSquigLinkFetch()"
/>
<el-button
type="primary"
:loading="squigLinkLoading"
:disabled="!squigLinkUrl.trim()"
@click="handleSquigLinkFetch()"
>
抓取
</el-button>
</div>
<div v-if="squigLinkCsv" class="squiglink-import-success">
<el-icon style="color: var(--el-color-success)"><CircleCheck /></el-icon>
<span>频响数据已抓取保存时将上传到 S3</span>
<el-button type="primary" link size="small" @click="squigLinkCsv = ''; squigLinkDataUrl = ''">清除</el-button>
</div>
<div v-if="squigLinkDataUrl" class="squiglink-data-url">
{{ squigLinkDataUrl }}
</div>
</div>
<el-form-item label="品牌名称" prop="brand_name">
<el-select
v-model="formData.brand_name"
placeholder="请选择品牌"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="brand in brandOptions"
:key="brand.id"
:label="brand.name"
:value="brand.name"
/>
</el-select>
<div v-if="showBrandNotInDbTip" class="brand-not-in-db-tip">
品牌{{ (formData.brand_name || '').trim() }}尚未在品牌库中可继续填写并保存保存新型号时将自动创建该品牌
</div>
</el-form-item>
<el-form-item label="型号名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入型号名称"
maxlength="100"
/>
</el-form-item>
<el-form-item label="形式" prop="form">
<el-select
v-model="formData.form"
placeholder="请选择佩戴方式"
style="width: 100%"
>
<el-option label="入耳式" value="in-ear" />
<el-option label="头戴式" value="over-ear" />
<el-option label="耳塞式" value="earbud" />
</el-select>
</el-form-item>
<el-form-item label="阻抗" prop="rig">
<el-input
v-model="formData.rig"
placeholder="例如:32Ω"
maxlength="100"
/>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input
v-model="formData.source"
placeholder="型号来源"
maxlength="100"
/>
</el-form-item>
<el-form-item label="频响文件" prop="measurement_file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:file-list="fileList"
:limit="1"
accept=".csv,.txt,.json"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或<em>点击上传</em>
</div>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" @click="handleSubmit(false)" :loading="submitLoading">
确定
</el-button>
<el-button
v-if="!formData.id"
type="success"
@click="handleSubmit(true)"
:loading="submitLoading"
>
确定并进行下一个
</el-button>
</template>
</el-dialog>
<!-- squig.link 候选选择对话框 -->
<el-dialog
v-model="squigLinkCandidateDialogVisible"
title="选择测量数据"
width="520px"
>
<p class="squiglink-candidate-tip">找到多个匹配的测量文件请选择一个</p>
<ul class="squiglink-candidate-list">
<li
v-for="item in squigLinkCandidates"
:key="item.fileName"
class="squiglink-candidate-item"
@click="handleSquigLinkCandidateSelect(item.fileName)"
>
<span class="squiglink-candidate-name">{{ item.fileName }}</span>
<span class="squiglink-candidate-brand">{{ item.brandName }}</span>
</li>
</ul>
<template #footer>
<el-button @click="squigLinkCandidateDialogVisible = false">取消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
Link,
CircleCheck,
UploadFilled
} from '@element-plus/icons-vue'
import {
createModel,
updateModel,
fetchFromSquigLink
} from '@/api/model'
import { createBrand } from '@/api/brand'
const props = defineProps({
modelValue: { type: Boolean, default: false },
brandOptions: { type: Array, default: () => [] },
editData: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'success', 'reload-brands'])
const formRef = ref(null)
const uploadRef = ref(null)
const submitLoading = ref(false)
const fileList = ref([])
const uploadedFile = ref(null)
const dialogTitle = ref('新型号')
// squig.link 导入状态
const squigLinkUrl = ref('')
const squigLinkLoading = ref(false)
const squigLinkCsv = ref('')
const squigLinkDataUrl = ref('')
const squigLinkCandidates = ref([])
const squigLinkCandidateDialogVisible = ref(false)
const formData = reactive({
id: null,
brand_name: '',
name: '',
form: '',
rig: '',
source: '',
eq_key: ''
})
const formRules = {
brand_name: [
{ required: true, message: '请选择品牌名称', trigger: 'change' }
],
name: [
{ required: true, message: '请输入型号名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
]
}
// ---- 品牌检测 ----
function brandNameExistsInOptions(name) {
const n = (name || '').trim().toLowerCase()
if (!n) return false
return props.brandOptions.some((b) => String(b.name || '').trim().toLowerCase() === n)
}
const showBrandNotInDbTip = computed(() => {
if (formData.id) return false
const bn = (formData.brand_name || '').trim()
if (!bn) return false
return !brandNameExistsInOptions(formData.brand_name)
})
// ---- 文件名解析 ----
function filenameStem(filename) {
if (!filename || typeof filename !== 'string') return ''
return filename.replace(/\.[^/.]+$/i, '').trim().replace(/\s+/g, ' ')
}
function parseBrandAndModelFromFilename(filename, brandsFromApi = []) {
const stem = filenameStem(filename)
if (!stem) return { brand_name: '', name: '' }
const lowerStem = stem.toLowerCase()
const brandNames = [
...new Set(
(brandsFromApi || [])
.map((b) => (b && b.name != null ? String(b.name).trim() : ''))
.filter(Boolean)
)
]
brandNames.sort((a, b) => b.length - a.length)
for (const brand of brandNames) {
if (stem === brand || lowerStem === brand.toLowerCase()) {
return { brand_name: brand, name: '' }
}
const rest = brand + ' '
if (lowerStem.startsWith(rest.toLowerCase())) {
return { brand_name: brand, name: stem.slice(rest.length).trim() }
}
}
const i = stem.indexOf(' ')
if (i === -1) return { brand_name: '', name: stem }
return { brand_name: stem.slice(0, i).trim(), name: stem.slice(i + 1).trim() }
}
// ---- 文件上传 ----
const handleFileChange = async (file) => {
uploadedFile.value = file.raw
fileList.value = [file]
formData.source = 'Eafonyoung'
const fname = file.name || file.raw?.name || ''
if (!formData.id) {
const { brand_name, name } = parseBrandAndModelFromFilename(fname, props.brandOptions)
formData.brand_name = brand_name
formData.name = name
formData.rig = '711'
}
}
const handleFileRemove = () => {
uploadedFile.value = null
fileList.value = []
}
// ---- squig.link 导入 ----
const handleSquigLinkFetch = async (selectedFile) => {
if (!selectedFile && !squigLinkUrl.value.trim()) {
ElMessage.warning('请输入 squig.link 分享链接')
return
}
squigLinkLoading.value = true
squigLinkCandidates.value = []
squigLinkCandidateDialogVisible.value = false
try {
const res = await fetchFromSquigLink(squigLinkUrl.value.trim(), selectedFile || undefined)
if (res.code !== 1) {
ElMessage.error(res.msg || '抓取失败')
return
}
const data = res.data
if (data.matches && data.matches.length > 1) {
squigLinkCandidates.value = data.matches
squigLinkCandidateDialogVisible.value = true
return
}
if (data.csv_content) {
squigLinkCsv.value = data.csv_content
squigLinkDataUrl.value = data.data_url || ''
if (!formData.id) {
if (data.brand_name) formData.brand_name = data.brand_name
if (data.model_name) formData.name = data.model_name
formData.source = 'Eafonyoung'
formData.form = data.form || formData.form || 'in-ear'
formData.rig = formData.rig || '711'
}
ElMessage.success('频响数据抓取成功,已自动填充表单')
}
} catch (error) {
console.error('SquigLink fetch error:', error)
ElMessage.error(error.message || '抓取失败')
} finally {
squigLinkLoading.value = false
}
}
const handleSquigLinkCandidateSelect = (fileName) => {
squigLinkCandidateDialogVisible.value = false
handleSquigLinkFetch(fileName)
}
// ---- 品牌自动创建 ----
async function ensureBrandExistsForNewModel(brandName) {
const bn = (brandName || '').trim()
if (!bn) return true
if (brandNameExistsInOptions(bn)) return true
try {
await createBrand({ name: bn })
} catch {
emit('reload-brands')
if (!brandNameExistsInOptions(bn)) return false
return true
}
emit('reload-brands')
return true
}
// ---- 提交 ----
const handleSubmit = async (continueAfter = false) => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
const brandTrimmed = formData.brand_name.trim()
if (!formData.id) {
const brandOk = await ensureBrandExistsForNewModel(brandTrimmed)
if (!brandOk) return
}
const formDataUpload = new FormData()
formDataUpload.append('brand_name', brandTrimmed)
formDataUpload.append('name', formData.name)
formDataUpload.append('form', formData.form)
formDataUpload.append('rig', formData.rig)
formDataUpload.append('source', formData.source)
formDataUpload.append('eq_key', formData.eq_key || '')
if (uploadedFile.value) {
formDataUpload.append('measurement_file', uploadedFile.value)
}
if (squigLinkCsv.value) {
formDataUpload.append('squiglink_csv', squigLinkCsv.value)
}
const res = formData.id
? await updateModel(formData.id, formDataUpload, { isFormData: true })
: await createModel(formDataUpload, { isFormData: true })
if (res.code === 1) {
ElMessage.success(formData.id ? '更新成功' : '创建成功')
if (continueAfter && !formData.id) {
resetFormForNext()
} else {
emit('update:modelValue', false)
}
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
})
}
// ---- 重置 ----
const resetFormForNext = () => {
formData.id = null
formData.brand_name = ''
formData.name = ''
formData.form = ''
formData.rig = ''
formData.source = ''
formData.eq_key = ''
uploadedFile.value = null
fileList.value = []
squigLinkUrl.value = ''
squigLinkCsv.value = ''
squigLinkDataUrl.value = ''
squigLinkCandidates.value = []
if (formRef.value) formRef.value.resetFields()
if (uploadRef.value) uploadRef.value.clearFiles()
}
const handleDialogClose = () => {
formData.id = null
formData.brand_name = ''
formData.name = ''
formData.form = ''
formData.rig = ''
formData.source = ''
formData.eq_key = ''
uploadedFile.value = null
fileList.value = []
squigLinkUrl.value = ''
squigLinkCsv.value = ''
squigLinkDataUrl.value = ''
squigLinkCandidates.value = []
squigLinkCandidateDialogVisible.value = false
if (formRef.value) formRef.value.resetFields()
if (uploadRef.value) uploadRef.value.clearFiles()
}
// ---- 对外方法:父组件调用以打开编辑模式 ----
function openForEdit(row) {
dialogTitle.value = '编辑型号'
formData.id = row.id
formData.brand_name = row.brand_name
formData.name = row.name
formData.form = row.form || ''
formData.rig = row.rig || ''
formData.source = row.source || ''
formData.eq_key = row.eq_key || ''
}
function openForAdd() {
dialogTitle.value = '新型号'
}
defineExpose({ openForEdit, openForAdd })
</script>
<style scoped>
.brand-not-in-db-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
color: var(--el-color-warning);
}
.squiglink-import-section {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.06);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.squiglink-import-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: #cbd5e1;
}
.squiglink-import-form {
display: flex;
gap: 8px;
}
.squiglink-import-success {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: #94a3b8;
}
.squiglink-data-url {
margin-top: 4px;
font-size: 11px;
line-height: 1.5;
color: #64748b;
word-break: break-all;
}
.squiglink-candidate-tip {
margin: 0 0 12px;
font-size: 13px;
color: #94a3b8;
}
.squiglink-candidate-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
}
.squiglink-candidate-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.08);
cursor: pointer;
transition: background 0.15s;
}
.squiglink-candidate-item:hover {
background: rgba(148, 163, 184, 0.18);
}
.squiglink-candidate-name {
font-size: 13px;
color: #e2e8f0;
font-weight: 500;
}
.squiglink-candidate-brand {
font-size: 12px;
color: #64748b;
}
</style>
@@ -0,0 +1,302 @@
<template>
<el-dialog
:model-value="modelValue"
title="推送到搜索"
width="480px"
:close-on-click-modal="false"
:close-on-press-escape="!pushProgressRunning"
:show-close="!pushProgressRunning"
@update:model-value="$emit('update:modelValue', $event)"
@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="$emit('update:modelValue', false)">
{{ pushProgressRunning ? '处理中' : '关闭' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue'
import { validatePushToMeilisearch, pushToMeilisearch } from '@/api/model'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
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 pushProgressRunning = computed(
() => pushSteps.validate === 'loading' || pushSteps.push === 'loading'
)
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) {
emit('update:modelValue', 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'
}
}
defineExpose({ runPushToSearch })
</script>
<style scoped>
.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: #e2e8f0;
}
.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>
@@ -0,0 +1,59 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="520px"
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-if="loading" class="push-view-loading">
<el-icon class="is-loading"><Loading /></el-icon>
加载中
</div>
<template v-else-if="!pushed">
<el-empty description="还没推送" :image-size="80" />
</template>
<el-descriptions v-else :column="1" border>
<el-descriptions-item label="ID">{{ document.id }}</el-descriptions-item>
<el-descriptions-item label="品牌">{{ document.brand_name }}</el-descriptions-item>
<el-descriptions-item label="型号">{{ document.name }}</el-descriptions-item>
<el-descriptions-item label="佩戴方式">{{ formatFormLabel(document.form) }}</el-descriptions-item>
<el-descriptions-item label="阻抗">{{ document.rig || '-' }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ document.source || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { Loading } from '@element-plus/icons-vue'
defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '查看推送' },
loading: { type: Boolean, default: false },
pushed: { type: Boolean, default: false },
document: { type: Object, default: () => ({}) }
})
defineEmits(['update:modelValue'])
function formatFormLabel(form) {
if (form === 'in-ear') return '入耳式'
if (form === 'over-ear') return '头戴式'
if (form === 'earbud') return '耳塞式'
return form || '-'
}
</script>
<style scoped>
.push-view-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 0;
color: #94a3b8;
}
</style>
@@ -0,0 +1,190 @@
import { ref, reactive, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getModels } from '@/api/model'
import { getBrands } from '@/api/brand'
export function useModelList(tableRef) {
const loading = ref(false)
const tableData = ref([])
const selectedRows = ref([])
const selectedIds = ref([])
const brandOptions = ref([])
/** 与接口一致:id | create_at */
const sortBy = ref('id')
/** asc | desc */
const sortOrder = ref('desc')
const searchForm = reactive({
brand_name: '',
name: ''
})
const pagination = reactive({
page: 1,
pageSize: 50,
total: 0
})
// ---- 数据加载 ----
const loadData = async () => {
loading.value = true
try {
const res = await getModels({
skip: (pagination.page - 1) * pagination.pageSize,
limit: pagination.pageSize,
brand_name: searchForm.brand_name || undefined,
name: searchForm.name || undefined,
sort_by: sortBy.value,
sort_order: sortOrder.value
})
if (res.code === 1 && res.data) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
} else if (res.code === 2) {
tableData.value = []
pagination.total = 0
}
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
const loadBrands = async () => {
try {
const res = await getBrands({ skip: 0, limit: 1000 })
if (res.code === 1 && res.data) {
brandOptions.value = res.data.items || []
} else if (res.code === 2) {
brandOptions.value = []
}
} catch (error) {
console.error('加载品牌列表失败:', error)
}
}
// ---- 搜索 / 重置 ----
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
searchForm.brand_name = ''
searchForm.name = ''
pagination.page = 1
loadData()
}
// ---- 排序 / 分页 ----
const handleSortChange = ({ prop, order }) => {
if (order === 'ascending') {
sortBy.value = prop
sortOrder.value = 'asc'
} else if (order === 'descending') {
sortBy.value = prop
sortOrder.value = 'desc'
} else {
sortBy.value = 'id'
sortOrder.value = 'desc'
nextTick(() => {
tableRef.value?.sort('id', 'descending')
})
}
pagination.page = 1
loadData()
}
const handleSizeChange = () => {
loadData()
}
const handlePageChange = () => {
loadData()
}
// ---- 选择 ----
const handleSelectionChange = (selection) => {
selectedRows.value = selection
selectedIds.value = selection.map((row) => row.id)
}
// ---- 复制 ----
const handleCopyName = (row) => {
const text = `${row.brand_name} ${row.name}`
navigator.clipboard.writeText(text).then(
() => ElMessage.success(`已复制: ${text}`),
() => ElMessage.error('复制失败')
)
}
const handleCopyBrand = (row) => {
const text = row.brand_name
navigator.clipboard.writeText(text).then(
() => ElMessage.success(`已复制: ${text}`),
() => ElMessage.error('复制失败')
)
}
const handleCopySelectedNames = () => {
if (selectedRows.value.length === 0) return
const text = selectedRows.value
.map((row) => `${row.brand_name} ${row.name}`)
.join('\n')
navigator.clipboard.writeText(text).then(
() => ElMessage.success(`已复制 ${selectedRows.value.length}`),
() => ElMessage.error('复制失败')
)
}
// ---- 格式化工具 ----
function formatDateTime(value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatFormLabel(form) {
if (form === 'in-ear') return '入耳式'
if (form === 'over-ear') return '头戴式'
if (form === 'earbud') return '耳塞式'
return form || '-'
}
return {
loading,
tableData,
selectedRows,
selectedIds,
brandOptions,
sortBy,
sortOrder,
searchForm,
pagination,
loadData,
loadBrands,
handleSearch,
handleReset,
handleSortChange,
handleSizeChange,
handlePageChange,
handleSelectionChange,
handleCopyName,
handleCopyBrand,
handleCopySelectedNames,
formatDateTime,
formatFormLabel
}
}
File diff suppressed because it is too large Load Diff