Enhance backend functionality and frontend UI
- Updated main.py to include authentication for brand, model, and OTA routers. - Added new OTA schemas in schemas.py for version management. - Enhanced model retrieval with sorting options in models.py. - Improved model update functionality to support multipart/form-data uploads. - Updated frontend layout and styles for a more modern look, including new font integration. - Implemented login route and authentication checks in router/index.js. - Added sorting capabilities in model table and improved file handling in model view. - Updated requirements.txt to include PyJWT for token management.
This commit is contained in:
@@ -60,15 +60,18 @@
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
:default-sort="{ prop: 'id', order: 'descending' }"
|
||||
@selection-change="handleSelectionChange"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||
<el-table-column prop="brand_name" label="品牌名称" width="150" />
|
||||
<el-table-column prop="name" label="型号名称" />
|
||||
<el-table-column prop="form" label="佩戴方式" width="120">
|
||||
@@ -81,7 +84,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="rig" label="阻抗" width="120" />
|
||||
<el-table-column prop="source" label="来源" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="create_at" label="创建时间" width="180" />
|
||||
<el-table-column prop="create_at" label="创建时间" width="180" sortable="custom" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
@@ -144,6 +147,9 @@
|
||||
: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
|
||||
@@ -192,11 +198,6 @@
|
||||
<div class="el-upload__text">
|
||||
拖拽文件到此处或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 csv/txt/json 格式,保留原文件名
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -211,11 +212,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus, Upload, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { getModels, createModel, updateModel, deleteModel, pushToMeilisearch } from '@/api/model'
|
||||
import { getBrands } from '@/api/brand'
|
||||
import { getBrands, createBrand } from '@/api/brand'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -224,6 +225,11 @@ const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新型号')
|
||||
const formRef = ref(null)
|
||||
const uploadRef = ref(null)
|
||||
const tableRef = ref(null)
|
||||
/** 与接口一致:id | create_at */
|
||||
const sortBy = ref('id')
|
||||
/** asc | desc */
|
||||
const sortOrder = ref('desc')
|
||||
const brandOptions = ref([])
|
||||
const selectedIds = ref([])
|
||||
const fileList = ref([])
|
||||
@@ -252,6 +258,20 @@ const formData = reactive({
|
||||
eq_key: ''
|
||||
})
|
||||
|
||||
function brandNameExistsInOptions(name) {
|
||||
const n = (name || '').trim().toLowerCase()
|
||||
if (!n) return false
|
||||
return brandOptions.value.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)
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
brand_name: [
|
||||
{ required: true, message: '请选择品牌名称', trigger: 'change' }
|
||||
@@ -262,12 +282,73 @@ const formRules = {
|
||||
]
|
||||
}
|
||||
|
||||
/** 去掉扩展名、压缩连续空格 */
|
||||
function filenameStem(filename) {
|
||||
if (!filename || typeof filename !== 'string') return ''
|
||||
return filename.replace(/\.[^/.]+$/i, '').trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名解析品牌、型号:优先用品牌接口返回的名称做「最长前缀」精确匹配(支持多词品牌),
|
||||
* 否则回退为「第一个空格」左侧品牌、右侧型号。
|
||||
* @param {string} filename
|
||||
* @param {{ name: string }[]} brandsFromApi
|
||||
*/
|
||||
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 = (file) => {
|
||||
const handleFileChange = async (file) => {
|
||||
uploadedFile.value = file.raw
|
||||
fileList.value = [file]
|
||||
// 自动填写来源为 Eafonyoung
|
||||
formData.source = 'Eafonyoung'
|
||||
const fname = file.name || file.raw?.name || ''
|
||||
// 仅「新型号」时根据文件名自动填品牌、型号与默认阻抗,避免编辑时被覆盖
|
||||
if (!formData.id) {
|
||||
if (!brandOptions.value.length) {
|
||||
await loadBrands()
|
||||
}
|
||||
const { brand_name, name } = parseBrandAndModelFromFilename(fname, brandOptions.value)
|
||||
formData.brand_name = brand_name
|
||||
formData.name = name
|
||||
formData.rig = '711'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件移除
|
||||
@@ -282,12 +363,50 @@ const loadBrands = async () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新型号提交前:若品牌不在库中则先创建品牌(忽略大小写比对) */
|
||||
async function ensureBrandExistsForNewModel(brandName) {
|
||||
const bn = (brandName || '').trim()
|
||||
if (!bn) return true
|
||||
if (brandNameExistsInOptions(bn)) return true
|
||||
try {
|
||||
await createBrand({ name: bn })
|
||||
} catch {
|
||||
await loadBrands()
|
||||
if (!brandNameExistsInOptions(bn)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
await loadBrands()
|
||||
return true
|
||||
}
|
||||
|
||||
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 loadData = async () => {
|
||||
loading.value = true
|
||||
@@ -296,7 +415,9 @@ const loadData = async () => {
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
brand_name: searchForm.brand_name || undefined,
|
||||
name: searchForm.name || undefined
|
||||
name: searchForm.name || undefined,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value
|
||||
})
|
||||
|
||||
if (res.code === 1 && res.data) {
|
||||
@@ -385,9 +506,17 @@ const handleSubmit = async () => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const brandTrimmed = formData.brand_name.trim()
|
||||
if (!formData.id) {
|
||||
const brandOk = await ensureBrandExistsForNewModel(brandTrimmed)
|
||||
if (!brandOk) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 始终使用 FormData 格式提交(后端需要 form-data)
|
||||
const formDataUpload = new FormData()
|
||||
formDataUpload.append('brand_name', formData.brand_name)
|
||||
formDataUpload.append('brand_name', brandTrimmed)
|
||||
formDataUpload.append('name', formData.name)
|
||||
formDataUpload.append('form', formData.form)
|
||||
formDataUpload.append('rig', formData.rig)
|
||||
@@ -399,8 +528,9 @@ const handleSubmit = async () => {
|
||||
formDataUpload.append('measurement_file', uploadedFile.value)
|
||||
}
|
||||
|
||||
const api = formData.id ? updateModel : createModel
|
||||
const res = await api(formDataUpload, { isFormData: true })
|
||||
const res = formData.id
|
||||
? await updateModel(formData.id, formDataUpload, { isFormData: true })
|
||||
: await createModel(formDataUpload, { isFormData: true })
|
||||
|
||||
if (res.code === 1) {
|
||||
ElMessage.success(formData.id ? '更新成功' : '创建成功')
|
||||
@@ -534,4 +664,11 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.brand-not-in-db-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user