modex/index.vue 分割
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+112
-1154
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user