优化体验,新增多项功能,美化界面

This commit is contained in:
eafonyang
2026-06-10 15:37:20 +08:00
parent bb1c851e39
commit 25a45c7cbd
18 changed files with 1213 additions and 302 deletions
+4 -3
View File
@@ -8,11 +8,12 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.0",
"element-plus": "^2.3.14",
"@element-plus/icons-vue": "^2.1.0"
"vue": "^3.3.4",
"vue-json-pretty": "^2.6.0",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.4",
+13
View File
@@ -20,6 +20,9 @@ importers:
vue:
specifier: ^3.3.4
version: 3.5.35
vue-json-pretty:
specifier: ^2.6.0
version: 2.6.0(vue@3.5.35)
vue-router:
specifier: ^4.2.4
version: 4.6.4(vue@3.5.35)
@@ -486,6 +489,12 @@ packages:
vue-component-type-helpers@3.3.3:
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
vue-json-pretty@2.6.0:
resolution: {integrity: sha512-glz1aBVS35EO8+S9agIl3WOQaW2cJZW192UVKTuGmryx01ZvOVWc4pR3t+5UcyY4jdOfBUgVHjcpRpcnjRhCAg==}
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}
peerDependencies:
vue: '>=3.0.0'
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
@@ -899,6 +908,10 @@ snapshots:
vue-component-type-helpers@3.3.3: {}
vue-json-pretty@2.6.0(vue@3.5.35):
dependencies:
vue: 3.5.35
vue-router@4.6.4(vue@3.5.35):
dependencies:
'@vue/devtools-api': 6.6.4
+50
View File
@@ -60,6 +60,56 @@ export function updateModel(id, data, config = {}) {
})
}
/**
* 获取型号 EQ 缓存(Redis
* @param {number} id - 型号 ID
*/
export function getModelEqCache(id) {
return request({
url: `/models/${id}/eq-cache`,
method: 'get',
timeout: 30000
})
}
/**
* 获取型号 EQ 缓存单个 hash field 的 value
* @param {number} id - 型号 ID
* @param {string} fieldKey - hash field 名称
*/
export function getModelEqCacheField(id, fieldKey) {
return request({
url: `/models/${id}/eq-cache/field`,
method: 'get',
params: { key: fieldKey },
timeout: 30000
})
}
/**
* 获取型号在 Meilisearch 中的推送数据
* @param {number} id - 型号 ID
*/
export function getModelMeilisearch(id) {
return request({
url: `/models/${id}/meilisearch`,
method: 'get',
timeout: 30000
})
}
/**
* 获取型号在 S3 上的频响 CSV 内容(仅 Eafonyoung
* @param {number} id - 型号 ID
*/
export function getModelMeasurement(id) {
return request({
url: `/models/${id}/measurement`,
method: 'get',
timeout: 30000
})
}
/**
* 删除型号
* @param {number} id - 型号 ID
+520 -19
View File
@@ -81,25 +81,56 @@
<span v-else>{{ scope.row.form || '-' }}</span>
</template>
</el-table-column>
<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">
<el-table-column prop="create_at" label="创建时间" width="180" sortable="custom">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row)"
>
删除
</el-button>
{{ formatDateTime(scope.row.create_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="scope">
<div class="row-actions">
<el-button
type="primary"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row)"
>
删除
</el-button>
<el-dropdown
trigger="click"
class="row-actions-more"
@command="(cmd) => handleMoreAction(cmd, scope.row)"
>
<el-button
type="success"
size="small"
:loading="moreActionLoadingId === scope.row.id"
>
更多操作
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="push">查看搜索</el-dropdown-item>
<el-dropdown-item command="eq">查看 EQ 缓存</el-dropdown-item>
<el-dropdown-item
v-if="isEafonyoungSource(scope.row.source)"
command="csv"
>
查看 CSV
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
@@ -208,6 +239,114 @@
</template>
</el-dialog>
<el-dialog
v-model="pushViewDialogVisible"
:title="pushViewDialogTitle"
width="520px"
@closed="resetPushViewDialog"
>
<div v-if="pushViewLoading" class="push-view-loading">
<el-icon class="is-loading"><Loading /></el-icon>
加载中
</div>
<template v-else-if="!pushViewPushed">
<el-empty description="还没推送" :image-size="80" />
</template>
<el-descriptions v-else :column="1" border>
<el-descriptions-item label="ID">{{ pushViewDocument.id }}</el-descriptions-item>
<el-descriptions-item label="品牌">{{ pushViewDocument.brand_name }}</el-descriptions-item>
<el-descriptions-item label="型号">{{ pushViewDocument.name }}</el-descriptions-item>
<el-descriptions-item label="佩戴方式">{{ formatFormLabel(pushViewDocument.form) }}</el-descriptions-item>
<el-descriptions-item label="阻抗">{{ pushViewDocument.rig || '-' }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ pushViewDocument.source || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="pushViewDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="eqCacheDialogVisible"
:title="eqCacheDialogTitle"
width="720px"
class="eq-cache-dialog"
>
<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="eqCacheDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="csvDialogVisible"
:title="csvDialogTitle"
width="720px"
class="csv-dialog"
@closed="resetCsvDialog"
>
<p v-if="csvS3Key" class="csv-s3-key">{{ csvS3Key }}</p>
<el-input
v-model="csvContent"
type="textarea"
:rows="18"
readonly
placeholder="暂无内容"
class="csv-content"
/>
<template #footer>
<el-button @click="csvDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="pushProgressVisible"
title="推送到搜索"
@@ -284,7 +423,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
@@ -294,17 +433,24 @@ import {
UploadFilled,
Loading,
CircleCheck,
CircleClose
CircleClose,
ArrowDown
} from '@element-plus/icons-vue'
import {
getModels,
createModel,
updateModel,
deleteModel,
getModelMeasurement,
getModelMeilisearch,
getModelEqCache,
getModelEqCacheField,
validatePushToMeilisearch,
pushToMeilisearch
} from '@/api/model'
import { getBrands, createBrand } from '@/api/brand'
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
const loading = ref(false)
const submitLoading = ref(false)
@@ -335,6 +481,26 @@ const brandOptions = ref([])
const selectedIds = ref([])
const fileList = ref([])
const uploadedFile = ref(null)
const csvDialogVisible = ref(false)
const csvDialogTitle = ref('频响 CSV')
const csvContent = ref('')
const csvS3Key = ref('')
const pushViewDialogVisible = ref(false)
const pushViewDialogTitle = ref('查看推送')
const pushViewLoading = ref(false)
const pushViewPushed = ref(false)
const pushViewDocument = ref({})
const moreActionLoadingId = ref(null)
const eqCacheDialogVisible = ref(false)
const eqCacheDialogTitle = ref('查看 EQ 缓存')
/** idle | loading | empty | list */
const eqCacheView = ref('idle')
const eqCacheRedisKey = ref('')
const eqCacheFieldKeys = ref([])
const eqCacheModelId = ref(null)
const eqCacheActiveKey = ref('')
const eqCacheFieldLoadingKey = ref('')
const eqCacheFieldData = ref(null)
const searchForm = reactive({
brand_name: '',
@@ -556,6 +722,189 @@ const handleAdd = () => {
dialogVisible.value = true
}
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 || '-'
}
function resetPushViewDialog() {
pushViewLoading.value = false
pushViewPushed.value = false
pushViewDocument.value = {}
pushViewDialogTitle.value = '查看推送'
}
const handleViewPush = async (row) => {
pushViewDialogTitle.value = `${row.brand_name} · ${row.name}`
pushViewDialogVisible.value = true
pushViewLoading.value = true
pushViewPushed.value = false
pushViewDocument.value = {}
moreActionLoadingId.value = row.id
try {
const res = await getModelMeilisearch(row.id)
if (res.code === 1 && res.data) {
pushViewPushed.value = !!res.data.pushed
pushViewDocument.value = res.data.document || {}
} else {
ElMessage.error(res.msg || '查询推送数据失败')
pushViewDialogVisible.value = false
}
} catch (error) {
console.error('查询推送数据失败:', error)
ElMessage.error(error.message || '查询推送数据失败')
pushViewDialogVisible.value = false
} finally {
pushViewLoading.value = false
moreActionLoadingId.value = null
}
}
function resetEqCacheDialog() {
eqCacheView.value = 'idle'
eqCacheRedisKey.value = ''
eqCacheFieldKeys.value = []
eqCacheModelId.value = null
eqCacheActiveKey.value = ''
eqCacheFieldLoadingKey.value = ''
eqCacheFieldData.value = null
eqCacheDialogTitle.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(eqCacheModelId.value, 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(eqCacheDialogVisible, (visible, wasVisible) => {
if (wasVisible && !visible) {
resetEqCacheDialog()
}
})
const handleViewEqCache = async (row) => {
eqCacheDialogTitle.value = `${row.brand_name} · ${row.name}`
eqCacheView.value = 'loading'
eqCacheRedisKey.value = ''
eqCacheFieldKeys.value = []
eqCacheModelId.value = row.id
eqCacheActiveKey.value = ''
eqCacheFieldData.value = null
moreActionLoadingId.value = row.id
eqCacheDialogVisible.value = true
try {
const res = await getModelEqCache(row.id)
if (res.code !== 1) {
ElMessage.error(res.msg || '获取 EQ 缓存失败')
eqCacheDialogVisible.value = false
return
}
const fieldKeys = Array.isArray(res.data?.field_keys) ? res.data.field_keys : []
eqCacheRedisKey.value = res.data?.redis_key || `${row.brand_name} ${row.name}`
eqCacheFieldKeys.value = fieldKeys
eqCacheView.value = fieldKeys.length > 0 ? 'list' : 'empty'
} catch (error) {
console.error('获取 EQ 缓存失败:', error)
ElMessage.error(error.message || '获取 EQ 缓存失败')
eqCacheDialogVisible.value = false
} finally {
moreActionLoadingId.value = null
}
}
const handleMoreAction = async (command, row) => {
if (command === 'push') {
await handleViewPush(row)
return
}
if (command === 'eq') {
await handleViewEqCache(row)
return
}
if (command === 'csv') {
await handleViewCsv(row)
}
}
function isEafonyoungSource(source) {
return String(source || '').trim().toLowerCase() === 'eafonyoung'
}
function resetCsvDialog() {
csvContent.value = ''
csvS3Key.value = ''
csvDialogTitle.value = '频响 CSV'
}
const handleViewCsv = async (row) => {
moreActionLoadingId.value = row.id
try {
const res = await getModelMeasurement(row.id)
if (res.code === 1 && res.data) {
csvDialogTitle.value = `${row.brand_name} · ${row.name}`
csvS3Key.value = res.data.s3_key || ''
csvContent.value = res.data.content || ''
csvDialogVisible.value = true
} else {
ElMessage.error(res.msg || '获取 CSV 失败')
}
} catch (error) {
console.error('获取 CSV 失败:', error)
ElMessage.error(error.message || '获取 CSV 失败')
} finally {
moreActionLoadingId.value = null
}
}
// 编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑型号'
@@ -961,4 +1310,156 @@ onMounted(() => {
line-height: 1.6;
color: #64748b;
}
.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;
}
.csv-content :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.push-view-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 0;
color: #94a3b8;
}
.row-actions {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
white-space: nowrap;
}
.row-actions-more {
flex-shrink: 0;
margin-left: 4px;
}
</style>