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:
yangy
2026-05-14 17:54:36 +08:00
parent b1d088a755
commit 661b85ce62
23 changed files with 2104 additions and 119 deletions
+152 -15
View File
@@ -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>