优化体验
This commit is contained in:
@@ -11,7 +11,7 @@ const logger = require('../config/logger');
|
|||||||
const { ApiResponse } = require('../utils/response');
|
const { ApiResponse } = require('../utils/response');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
const { fetchAndValidateCurve } = require('../services/curveClient');
|
const { fetchAndValidateCurve } = require('../services/curveClient');
|
||||||
const { uploadMeasurementToS3, getMeasurementFromS3 } = require('../services/measurementStorage');
|
const { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } = require('../services/measurementStorage');
|
||||||
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
|
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
|
||||||
const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink');
|
const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink');
|
||||||
|
|
||||||
@@ -499,6 +499,15 @@ router.put('/api/models/:model_id', upload.single('measurement_file'), async (re
|
|||||||
logger.info(`Measurement file uploaded to S3: ${s3Key}`);
|
logger.info(`Measurement file uploaded to S3: ${s3Key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若未上传新文件,且路径相关字段发生变更,则迁移 S3 上的旧 CSV
|
||||||
|
const hasNewFile = squiglink_csv || (req.file && req.file.originalname);
|
||||||
|
if (!hasNewFile) {
|
||||||
|
await moveMeasurementOnS3(
|
||||||
|
{ source: dbModel.source, form: dbModel.form, brandName: dbModel.brand_name, modelName: dbModel.name },
|
||||||
|
{ source: effSource, form: effForm, brandName: newBrandName, modelName: newName }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name;
|
if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name;
|
||||||
if (name !== undefined && name !== 'null') dbModel.name = name;
|
if (name !== undefined && name !== 'null') dbModel.name = name;
|
||||||
if (form !== undefined && form !== 'null') dbModel.form = form;
|
if (form !== undefined && form !== 'null') dbModel.form = form;
|
||||||
|
|||||||
+13
-10
@@ -113,19 +113,21 @@ router.get('/api/ota/', async (req, res) => {
|
|||||||
const verName = req.query.verName;
|
const verName = req.query.verName;
|
||||||
const model = req.query.model;
|
const model = req.query.model;
|
||||||
const status = req.query.status !== undefined ? parseInt(req.query.status, 10) : null;
|
const status = req.query.status !== undefined ? parseInt(req.query.status, 10) : null;
|
||||||
|
const beta = req.query.beta !== undefined ? parseInt(req.query.beta, 10) : null;
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (verCode !== null && !isNaN(verCode)) where.verCode = verCode;
|
if (verCode !== null && !isNaN(verCode)) where.verCode = verCode;
|
||||||
if (verName) where.verName = { [Op.like]: `%${verName}%` };
|
if (verName) where.verName = { [Op.like]: `%${verName}%` };
|
||||||
if (model) where.model = { [Op.like]: `%${model}%` };
|
if (model) where.model = { [Op.like]: `%${model}%` };
|
||||||
if (status !== null && !isNaN(status)) where.status = status;
|
if (status !== null && !isNaN(status)) where.status = status;
|
||||||
|
if (beta !== null && !isNaN(beta)) where.beta = beta;
|
||||||
|
|
||||||
const total = await Ota.count({ where });
|
const total = await Ota.count({ where });
|
||||||
const rows = await Ota.findAll({
|
const rows = await Ota.findAll({
|
||||||
where,
|
where,
|
||||||
offset: skip,
|
offset: skip,
|
||||||
limit,
|
limit,
|
||||||
order: [['verCode', 'DESC']],
|
order: [['id', 'DESC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${rows.length} OTA records, total=${total}`);
|
logger.info(`Found ${rows.length} OTA records, total=${total}`);
|
||||||
@@ -171,13 +173,13 @@ router.post('/api/ota/', async (req, res) => {
|
|||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`);
|
logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`);
|
||||||
|
|
||||||
// 检查版本号是否已存在
|
// 检查版本号是否已存在(同一 verCode + model + beta 视为唯一)
|
||||||
const existing = await Ota.findOne({
|
const existing = await Ota.findOne({
|
||||||
where: { verCode: data.verCode, model: data.model },
|
where: { verCode: data.verCode, model: data.model, beta: data.beta ?? 0 },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}`);
|
logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`);
|
||||||
return res.json(ApiResponse.error('该版本已存在'));
|
return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理日期字段
|
// 处理日期字段
|
||||||
@@ -213,17 +215,18 @@ router.put('/api/ota/:ota_id', async (req, res) => {
|
|||||||
return res.json(ApiResponse.noData('empty'));
|
return res.json(ApiResponse.noData('empty'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果更新版本号,检查是否冲突
|
// 如果更新版本号或灰度状态,检查是否冲突
|
||||||
const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode;
|
const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode;
|
||||||
const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model;
|
const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model;
|
||||||
|
const newBeta = data.beta !== undefined && data.beta !== null ? data.beta : dbOta.beta;
|
||||||
|
|
||||||
if (newVerCode !== dbOta.verCode || newModel !== dbOta.model) {
|
if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta) {
|
||||||
const existing = await Ota.findOne({
|
const existing = await Ota.findOne({
|
||||||
where: { verCode: newVerCode, model: newModel },
|
where: { verCode: newVerCode, model: newModel, beta: newBeta },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}`);
|
logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`);
|
||||||
return res.json(ApiResponse.error('该版本已存在'));
|
return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
* 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
||||||
* 正式环境:通过 IAM 角色访问 S3(无需配置密钥)
|
* 正式环境:通过 IAM 角色访问 S3(无需配置密钥)
|
||||||
*/
|
*/
|
||||||
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
|
|
||||||
const AWS_REGION = process.env.AWS_REGION || 'eu-central-1';
|
const AWS_REGION = process.env.AWS_REGION || 'eu-central-1';
|
||||||
@@ -107,8 +107,58 @@ async function getMeasurementFromS3(params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 S3 上迁移频响文件(复制到新路径后删除旧文件)
|
||||||
|
* @param {object} oldParams - 旧路径参数 { source, form, brandName, modelName }
|
||||||
|
* @param {object} newParams - 新路径参数
|
||||||
|
* @returns {Promise<string|null>} 新 S3 Key,若旧文件不存在则返回 null
|
||||||
|
*/
|
||||||
|
async function moveMeasurementOnS3(oldParams, newParams) {
|
||||||
|
const oldKey = buildMeasurementKey(oldParams);
|
||||||
|
const newKey = buildMeasurementKey(newParams);
|
||||||
|
|
||||||
|
if (oldKey === newKey) {
|
||||||
|
logger.info(`S3 move skipped: path unchanged (${oldKey})`);
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getS3Client();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 服务端复制(无需下载再上传)
|
||||||
|
await client.send(
|
||||||
|
new CopyObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
CopySource: `${S3_BUCKET}/${encodeURIComponent(oldKey).replace(/%2F/g, '/')}`,
|
||||||
|
Key: newKey,
|
||||||
|
ContentType: 'text/csv',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
logger.info(`S3 copied: ${oldKey} -> ${newKey}`);
|
||||||
|
|
||||||
|
// 复制成功后删除旧文件
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: oldKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
logger.info(`S3 deleted old: ${oldKey}`);
|
||||||
|
|
||||||
|
return newKey;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) {
|
||||||
|
logger.info(`S3 move skipped: old key not found (${oldKey})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logger.error(`S3 measurement move failed: ${e.message}`);
|
||||||
|
throw new Error(`S3 迁移失败:${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
uploadMeasurementToS3,
|
uploadMeasurementToS3,
|
||||||
getMeasurementFromS3,
|
getMeasurementFromS3,
|
||||||
|
moveMeasurementOnS3,
|
||||||
buildMeasurementKey,
|
buildMeasurementKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,16 +24,16 @@
|
|||||||
:class="{ 'is-active': isSidebarCollapsed }"
|
:class="{ 'is-active': isSidebarCollapsed }"
|
||||||
aria-label="主导航"
|
aria-label="主导航"
|
||||||
>
|
>
|
||||||
<router-link
|
<a
|
||||||
v-for="item in railNavItems"
|
v-for="item in railNavItems"
|
||||||
:key="item.path"
|
:key="item.title"
|
||||||
:to="item.path"
|
href="#"
|
||||||
class="sidebar-rail-item"
|
class="sidebar-rail-item"
|
||||||
:class="{ 'is-active': activeMenu === item.path }"
|
|
||||||
:title="item.title"
|
:title="item.title"
|
||||||
|
@click.prevent="handleRailClick"
|
||||||
>
|
>
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
</router-link>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -187,14 +187,15 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const railNavItems = [
|
const railNavItems = [
|
||||||
{ path: '/brand', title: '品牌管理', icon: Collection },
|
{ title: '耳机管理', icon: Headset },
|
||||||
{ path: '/model', title: '型号管理', icon: List },
|
{ title: '升级管理', icon: Upload },
|
||||||
{ path: '/ota', title: 'OTA 管理', icon: Document },
|
{ title: '分享码', icon: Share }
|
||||||
{ path: '/blacklist', title: '黑名单', icon: Warning },
|
|
||||||
{ path: '/ota-target-device', title: '定向升级', icon: Aim },
|
|
||||||
{ path: '/share-code/log', title: '分享日志', icon: Share }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function handleRailClick() {
|
||||||
|
isSidebarCollapsed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbTitle = computed(() => {
|
const breadcrumbTitle = computed(() => {
|
||||||
if (route.path === '/home') return ''
|
if (route.path === '/home') return ''
|
||||||
const t = route.meta?.title
|
const t = route.meta?.title
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
<el-option label="停用" :value="0" />
|
<el-option label="停用" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="灰度">
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.beta"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<el-option label="是" :value="1" />
|
||||||
|
<el-option label="否" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="版本号">
|
<el-form-item label="版本号">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="searchForm.verCode"
|
v-model="searchForm.verCode"
|
||||||
@@ -68,7 +79,16 @@
|
|||||||
<el-table :data="tableData" border stripe style="width: 100%">
|
<el-table :data="tableData" border stripe style="width: 100%">
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column prop="verCode" label="版本号" width="90" />
|
<el-table-column prop="verCode" label="版本号" width="90" />
|
||||||
<el-table-column prop="verName" label="版本名" width="120" show-overflow-tooltip />
|
<el-table-column prop="verName" label="版本名" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="vername-cell">
|
||||||
|
<span class="vername-text" :title="row.verName">{{ row.verName }}</span>
|
||||||
|
<el-tag v-if="getLatestTag(row)" :type="row.beta === 1 ? 'warning' : 'success'" size="small" class="latest-tag">
|
||||||
|
{{ row.beta === 1 ? 'latest beta' : 'latest' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="model" label="设备型号" width="120" show-overflow-tooltip />
|
<el-table-column prop="model" label="设备型号" width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="hw" label="硬件" width="70" />
|
<el-table-column prop="hw" label="硬件" width="70" />
|
||||||
<el-table-column prop="url" label="包地址" min-width="160">
|
<el-table-column prop="url" label="包地址" min-width="160">
|
||||||
@@ -102,6 +122,11 @@
|
|||||||
<el-tag v-else type="info" size="small">停用</el-tag>
|
<el-tag v-else type="info" size="small">停用</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_at" label="新增时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatBlackDate(row.create_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
<el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
@@ -388,6 +413,7 @@ const searchForm = reactive({
|
|||||||
verName: '',
|
verName: '',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
beta: undefined,
|
||||||
verCode: null
|
verCode: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -399,6 +425,23 @@ const pagination = reactive({
|
|||||||
|
|
||||||
const tableData = ref([])
|
const tableData = ref([])
|
||||||
|
|
||||||
|
/** 计算每个 (model, beta) 组合下的最大 verCode,用于显示 latest 标签 */
|
||||||
|
const latestVersionMap = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const row of tableData.value) {
|
||||||
|
const key = `${row.model || ''}__${row.beta}`
|
||||||
|
if (map[key] === undefined || row.verCode > map[key]) {
|
||||||
|
map[key] = row.verCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function getLatestTag(row) {
|
||||||
|
const key = `${row.model || ''}__${row.beta}`
|
||||||
|
return latestVersionMap.value[key] === row.verCode
|
||||||
|
}
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -551,6 +594,9 @@ const loadData = async () => {
|
|||||||
if (typeof searchForm.verCode === 'number') {
|
if (typeof searchForm.verCode === 'number') {
|
||||||
params.verCode = searchForm.verCode
|
params.verCode = searchForm.verCode
|
||||||
}
|
}
|
||||||
|
if (searchForm.beta === 0 || searchForm.beta === 1) {
|
||||||
|
params.beta = searchForm.beta
|
||||||
|
}
|
||||||
const res = await getOtaList(params)
|
const res = await getOtaList(params)
|
||||||
if (res.code === 1 && res.data) {
|
if (res.code === 1 && res.data) {
|
||||||
tableData.value = res.data.items || []
|
tableData.value = res.data.items || []
|
||||||
@@ -576,6 +622,7 @@ const handleReset = () => {
|
|||||||
searchForm.verName = ''
|
searchForm.verName = ''
|
||||||
searchForm.model = undefined
|
searchForm.model = undefined
|
||||||
searchForm.status = undefined
|
searchForm.status = undefined
|
||||||
|
searchForm.beta = undefined
|
||||||
searchForm.verCode = null
|
searchForm.verCode = null
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
loadData()
|
loadData()
|
||||||
@@ -999,4 +1046,22 @@ onMounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vername-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vername-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user