From acd3d4b92c24d63a40b59c8e1e24b2c3fd203c4f Mon Sep 17 00:00:00 2001 From: eafonyang Date: Mon, 17 Aug 2026 15:41:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=80=B3=E6=9C=BA=E9=98=BB?= =?UTF-8?q?=E6=8A=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/routes/headphoneImpedance.ts | 239 ++++++++++++ dashboard/backend/src/routes/index.ts | 2 + .../backend/src/schemas/headphoneImpedance.ts | 22 ++ dashboard/backend/src/schemas/index.ts | 1 + .../src/validators/headphoneImpedance.ts | 32 ++ dashboard/frontend/src/locales/langs/en-us.ts | 1 + dashboard/frontend/src/locales/langs/zh-cn.ts | 1 + .../frontend/src/router/elegant/imports.ts | 1 + .../frontend/src/router/elegant/routes.ts | 12 + .../frontend/src/router/elegant/transform.ts | 1 + .../src/service/api/headphone-impedance.ts | 47 +++ dashboard/frontend/src/service/api/index.ts | 1 + .../frontend/src/typings/api/dashboard.d.ts | 16 + .../frontend/src/typings/elegant-router.d.ts | 2 + .../src/views/headphone/impedance/index.vue | 355 ++++++++++++++++++ 15 files changed, 733 insertions(+) create mode 100644 dashboard/backend/src/routes/headphoneImpedance.ts create mode 100644 dashboard/backend/src/schemas/headphoneImpedance.ts create mode 100644 dashboard/backend/src/validators/headphoneImpedance.ts create mode 100644 dashboard/frontend/src/service/api/headphone-impedance.ts create mode 100644 dashboard/frontend/src/views/headphone/impedance/index.vue diff --git a/dashboard/backend/src/routes/headphoneImpedance.ts b/dashboard/backend/src/routes/headphoneImpedance.ts new file mode 100644 index 0000000..bc77e96 --- /dev/null +++ b/dashboard/backend/src/routes/headphoneImpedance.ts @@ -0,0 +1,239 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, and, desc, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { userHeadphoneImpedances } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { HeadphoneImpedanceCreateSchema, HeadphoneImpedanceUpdateSchema } from '../validators/headphoneImpedance.js'; + +const router: RouterType = Router(); + +// 所有接口需登录 +router.use('/api/headphone-impedance', authMiddleware); + +function toDict(item: typeof userHeadphoneImpedances.$inferSelect) { + return { + id: item.id, + mac_addr: item.macAddr, + device_model: item.deviceModel, + impedance_ohm: item.impedanceOhm, + headphone_brand: item.headphoneBrand, + headphone_model: item.headphoneModel, + headphone_brand_norm: item.headphoneBrandNorm, + headphone_model_norm: item.headphoneModelNorm, + ip_addr: item.ipAddr, + create_at: item.createAt ? item.createAt.toISOString() : null, + update_at: item.updateAt ? item.updateAt.toISOString() : null, + }; +} + +/** norm 字段生成规则:原始值 trim + 小写(与设备端上报行为一致) */ +function norm(value: string) { + return value.trim().toLowerCase(); +} + +// GET /api/headphone-impedance/ —— 分页列表(支持 mac/型号/品牌/耳机型号筛选) +router.get('/api/headphone-impedance/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const macAddr = req.query.mac_addr as string | undefined; + const deviceModel = req.query.device_model as string | undefined; + const headphoneBrand = req.query.headphone_brand as string | undefined; + const headphoneModel = req.query.headphone_model as string | undefined; + + const conditions = []; + if (macAddr) conditions.push(like(userHeadphoneImpedances.macAddr, `%${macAddr}%`)); + if (deviceModel) conditions.push(eq(userHeadphoneImpedances.deviceModel, deviceModel)); + // 品牌筛选对原始值与 norm 值分别模糊匹配(输入小写时也能命中大写原始值) + if (headphoneBrand) { + conditions.push( + like(userHeadphoneImpedances.headphoneBrand, `%${headphoneBrand}%`), + ); + } + if (headphoneModel) { + conditions.push( + like(userHeadphoneImpedances.headphoneModel, `%${headphoneModel}%`), + ); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(userHeadphoneImpedances) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select() + .from(userHeadphoneImpedances) + .where(whereClause) + .orderBy(desc(userHeadphoneImpedances.id)) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} headphone impedance records, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + res.json(ApiResponse.success({ items: rows.map(toDict), total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting headphone impedance list: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/headphone-impedance/:id +router.get('/api/headphone-impedance/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Getting headphone impedance: id=${id}`); + + const [item] = await db + .select() + .from(userHeadphoneImpedances) + .where(eq(userHeadphoneImpedances.id, id)) + .limit(1); + if (!item) { + logger.warn(`Headphone impedance not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + res.json(ApiResponse.success(toDict(item))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting headphone impedance: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/headphone-impedance/ +router.post('/api/headphone-impedance/', async (req: Request, res: Response) => { + try { + const parsed = HeadphoneImpedanceCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.issues.map((e: { message: string }) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Creating headphone impedance: mac=${data.mac_addr}, model=${data.device_model}, ohm=${data.impedance_ohm}`); + + const result = await db.insert(userHeadphoneImpedances).values({ + macAddr: data.mac_addr.trim(), + deviceModel: data.device_model.trim(), + impedanceOhm: data.impedance_ohm, + headphoneBrand: data.headphone_brand.trim(), + headphoneModel: data.headphone_model.trim(), + headphoneBrandNorm: norm(data.headphone_brand), + headphoneModelNorm: norm(data.headphone_model), + ipAddr: data.ip_addr ?? '', + }); + + const [created] = await db + .select() + .from(userHeadphoneImpedances) + .where(eq(userHeadphoneImpedances.id, result[0].insertId)) + .limit(1); + logger.info(`Headphone impedance created: id=${created.id}`); + res.json(ApiResponse.success(toDict(created))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating headphone impedance: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/headphone-impedance/:id +router.put('/api/headphone-impedance/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + + const parsed = HeadphoneImpedanceUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.issues.map((e: { message: string }) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Updating headphone impedance: id=${id}`); + + const [item] = await db + .select() + .from(userHeadphoneImpedances) + .where(eq(userHeadphoneImpedances.id, id)) + .limit(1); + if (!item) { + logger.warn(`Headphone impedance not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + const updateData: Partial = {}; + if (data.mac_addr !== undefined && data.mac_addr !== null) updateData.macAddr = data.mac_addr.trim(); + if (data.device_model !== undefined && data.device_model !== null) updateData.deviceModel = data.device_model.trim(); + if (data.impedance_ohm !== undefined && data.impedance_ohm !== null) updateData.impedanceOhm = data.impedance_ohm; + if (data.headphone_brand !== undefined && data.headphone_brand !== null) { + updateData.headphoneBrand = data.headphone_brand.trim(); + updateData.headphoneBrandNorm = norm(data.headphone_brand); + } + if (data.headphone_model !== undefined && data.headphone_model !== null) { + updateData.headphoneModel = data.headphone_model.trim(); + updateData.headphoneModelNorm = norm(data.headphone_model); + } + if (data.ip_addr !== undefined && data.ip_addr !== null) updateData.ipAddr = data.ip_addr; + + if (Object.keys(updateData).length > 0) { + await db.update(userHeadphoneImpedances).set(updateData).where(eq(userHeadphoneImpedances.id, id)); + } + logger.info(`Headphone impedance updated: id=${id}`); + + const [updated] = await db + .select() + .from(userHeadphoneImpedances) + .where(eq(userHeadphoneImpedances.id, id)) + .limit(1); + res.json(ApiResponse.success(toDict(updated))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating headphone impedance: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/headphone-impedance/:id +router.delete('/api/headphone-impedance/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Deleting headphone impedance: id=${id}`); + + const [item] = await db + .select() + .from(userHeadphoneImpedances) + .where(eq(userHeadphoneImpedances.id, id)) + .limit(1); + if (!item) { + logger.warn(`Headphone impedance not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + await db.delete(userHeadphoneImpedances).where(eq(userHeadphoneImpedances.id, id)); + logger.info(`Headphone impedance deleted: id=${id}`); + res.json(ApiResponse.success(null, 'success')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting headphone impedance: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index 56988f2..4121b71 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import otaRouter from './ota.js'; import blacklistRouter from './blacklist.js'; import otaTargetDeviceRouter from './otaTargetDevice.js'; import shareCodeLogsRouter from './shareCodeLogs.js'; +import headphoneImpedanceRouter from './headphoneImpedance.js'; import usersRouter from './users.js'; import dashboardRouter from './dashboard.js'; import siteRouter from './site.js'; @@ -35,6 +36,7 @@ const routes: Router[] = [ blacklistRouter, otaTargetDeviceRouter, shareCodeLogsRouter, + headphoneImpedanceRouter, usersRouter, dashboardRouter, meilisearchRouter, diff --git a/dashboard/backend/src/schemas/headphoneImpedance.ts b/dashboard/backend/src/schemas/headphoneImpedance.ts new file mode 100644 index 0000000..2cace70 --- /dev/null +++ b/dashboard/backend/src/schemas/headphoneImpedance.ts @@ -0,0 +1,22 @@ +import { mysqlTable, int, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +/** + * 用户耳机阻抗上报记录(表已存在于数据库,无需建表) + * + * 设备端上报:连接耳机后测得的阻抗 + 识别出的耳机品牌/型号。 + * *_norm 字段为对应原始值的小写规范化形式,由写入方生成。 + */ +export const userHeadphoneImpedances = mysqlTable('user_headphone_impedance', { + id: int('id').primaryKey().autoincrement(), + macAddr: varchar('mac_addr', { length: 255 }).notNull(), + deviceModel: varchar('device_model', { length: 50 }).notNull(), + impedanceOhm: int('impedance_ohm').notNull(), + headphoneBrand: varchar('headphone_brand', { length: 255 }).notNull(), + headphoneModel: varchar('headphone_model', { length: 255 }).notNull(), + headphoneBrandNorm: varchar('headphone_brand_norm', { length: 255 }).notNull(), + headphoneModelNorm: varchar('headphone_model_norm', { length: 255 }).notNull(), + ipAddr: varchar('ip_addr', { length: 100 }).notNull(), + createAt: datetime('create_at').notNull().default(sql`NOW()`), + updateAt: datetime('update_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/index.ts b/dashboard/backend/src/schemas/index.ts index 647eeae..dd1c04d 100644 --- a/dashboard/backend/src/schemas/index.ts +++ b/dashboard/backend/src/schemas/index.ts @@ -10,6 +10,7 @@ export { dashboardUsers } from './dashboardUser.js'; export { shareCodeLogs } from './shareCodeLog.js'; export { userActives } from './userActive.js'; export { userDevices } from './userDevice.js'; +export { userHeadphoneImpedances } from './headphoneImpedance.js'; // 官网 CMS export * from './www/index.js'; diff --git a/dashboard/backend/src/validators/headphoneImpedance.ts b/dashboard/backend/src/validators/headphoneImpedance.ts new file mode 100644 index 0000000..9315940 --- /dev/null +++ b/dashboard/backend/src/validators/headphoneImpedance.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const HeadphoneImpedanceCreateSchema = z.object({ + mac_addr: z.string().min(1, 'MAC 地址不能为空').max(255, 'MAC 地址最多 255 字符'), + device_model: z.string().min(1, '设备型号不能为空').max(50, '设备型号最多 50 字符'), + impedance_ohm: z + .number({ message: '阻抗须为数值' }) + .int('阻抗须为整数') + .min(0, '阻抗不能为负') + .max(10000, '阻抗不能超过 10000'), + headphone_brand: z.string().min(1, '耳机品牌不能为空').max(255, '耳机品牌最多 255 字符'), + headphone_model: z.string().min(1, '耳机型号不能为空').max(255, '耳机型号最多 255 字符'), + ip_addr: z.string().max(100, 'IP 地址最多 100 字符').optional().default(''), +}); + +export const HeadphoneImpedanceUpdateSchema = z.object({ + mac_addr: z.string().min(1).max(255).optional().nullable(), + device_model: z.string().min(1).max(50).optional().nullable(), + impedance_ohm: z + .number({ message: '阻抗须为数值' }) + .int('阻抗须为整数') + .min(0, '阻抗不能为负') + .max(10000, '阻抗不能超过 10000') + .optional() + .nullable(), + headphone_brand: z.string().min(1).max(255).optional().nullable(), + headphone_model: z.string().min(1).max(255).optional().nullable(), + ip_addr: z.string().max(100).optional().nullable(), +}); + +export type HeadphoneImpedanceCreateInput = z.infer; +export type HeadphoneImpedanceUpdateInput = z.infer; diff --git a/dashboard/frontend/src/locales/langs/en-us.ts b/dashboard/frontend/src/locales/langs/en-us.ts index be57ad8..4c05225 100644 --- a/dashboard/frontend/src/locales/langs/en-us.ts +++ b/dashboard/frontend/src/locales/langs/en-us.ts @@ -233,6 +233,7 @@ const local: App.I18n.Schema = { headphone: 'Headphones', headphone_brand: 'Brands', headphone_model: 'Models', + headphone_impedance: 'Impedance', upgrade: 'Upgrade', upgrade_ota: 'OTA', 'upgrade_ota-target-device': 'Target Devices', diff --git a/dashboard/frontend/src/locales/langs/zh-cn.ts b/dashboard/frontend/src/locales/langs/zh-cn.ts index c7332b1..705a78b 100644 --- a/dashboard/frontend/src/locales/langs/zh-cn.ts +++ b/dashboard/frontend/src/locales/langs/zh-cn.ts @@ -229,6 +229,7 @@ const local: App.I18n.Schema = { headphone: '耳机管理', headphone_brand: '品牌管理', headphone_model: '型号管理', + headphone_impedance: '耳机阻抗', upgrade: '升级管理', upgrade_ota: 'OTA 管理', 'upgrade_ota-target-device': '定向升级', diff --git a/dashboard/frontend/src/router/elegant/imports.ts b/dashboard/frontend/src/router/elegant/imports.ts index c319a41..2aefdb8 100644 --- a/dashboard/frontend/src/router/elegant/imports.ts +++ b/dashboard/frontend/src/router/elegant/imports.ts @@ -21,6 +21,7 @@ export const views: Record Promise import("@/views/_builtin/iframe-page/[url].vue"), login: () => import("@/views/_builtin/login/index.vue"), headphone_brand: () => import("@/views/headphone/brand/index.vue"), + headphone_impedance: () => import("@/views/headphone/impedance/index.vue"), headphone_model: () => import("@/views/headphone/model/index.vue"), home: () => import("@/views/home/index.vue"), meilisearch: () => import("@/views/meilisearch/index.vue"), diff --git a/dashboard/frontend/src/router/elegant/routes.ts b/dashboard/frontend/src/router/elegant/routes.ts index 1fbee0f..4cda9b6 100644 --- a/dashboard/frontend/src/router/elegant/routes.ts +++ b/dashboard/frontend/src/router/elegant/routes.ts @@ -62,6 +62,18 @@ export const generatedRoutes: GeneratedRoute[] = [ keepAlive: true } }, + { + name: 'headphone_impedance', + path: '/headphone/impedance', + component: 'view.headphone_impedance', + meta: { + title: 'headphone_impedance', + i18nKey: 'route.headphone_impedance', + icon: 'mdi:omega', + order: 3, + keepAlive: true + } + }, { name: 'headphone_model', path: '/headphone/model', diff --git a/dashboard/frontend/src/router/elegant/transform.ts b/dashboard/frontend/src/router/elegant/transform.ts index ba953d3..e038bd6 100644 --- a/dashboard/frontend/src/router/elegant/transform.ts +++ b/dashboard/frontend/src/router/elegant/transform.ts @@ -168,6 +168,7 @@ const routeMap: RouteMap = { "500": "/500", "headphone": "/headphone", "headphone_brand": "/headphone/brand", + "headphone_impedance": "/headphone/impedance", "headphone_model": "/headphone/model", "home": "/home", "iframe-page": "/iframe-page/:url", diff --git a/dashboard/frontend/src/service/api/headphone-impedance.ts b/dashboard/frontend/src/service/api/headphone-impedance.ts new file mode 100644 index 0000000..12feb91 --- /dev/null +++ b/dashboard/frontend/src/service/api/headphone-impedance.ts @@ -0,0 +1,47 @@ +import { request } from '../request'; +import type { PageParams } from './brand'; + +export function fetchGetHeadphoneImpedanceList( + params?: PageParams & { + mac_addr?: string; + device_model?: string; + headphone_brand?: string; + headphone_model?: string; + } +) { + return request>({ + url: '/headphone-impedance/', + method: 'get', + params + }); +} + +export function fetchGetHeadphoneImpedance(id: number) { + return request({ + url: `/headphone-impedance/${id}`, + method: 'get' + }); +} + +export function fetchCreateHeadphoneImpedance(data: Record) { + return request({ + url: '/headphone-impedance/', + method: 'post', + data + }); +} + +export function fetchUpdateHeadphoneImpedance(id: number, data: Record) { + return request({ + url: `/headphone-impedance/${id}`, + method: 'put', + data + }); +} + +export function fetchDeleteHeadphoneImpedance(id: number) { + return request({ + url: `/headphone-impedance/${id}`, + method: 'delete' + }); +} diff --git a/dashboard/frontend/src/service/api/index.ts b/dashboard/frontend/src/service/api/index.ts index 74a6e1d..ed57413 100644 --- a/dashboard/frontend/src/service/api/index.ts +++ b/dashboard/frontend/src/service/api/index.ts @@ -6,6 +6,7 @@ export * from './user'; export * from './ota'; export * from './blacklist'; export * from './ota-target-device'; +export * from './headphone-impedance'; export * from './share-code-log'; export * from './model'; export * from './meilisearch'; diff --git a/dashboard/frontend/src/typings/api/dashboard.d.ts b/dashboard/frontend/src/typings/api/dashboard.d.ts index f991b94..14d9ab0 100644 --- a/dashboard/frontend/src/typings/api/dashboard.d.ts +++ b/dashboard/frontend/src/typings/api/dashboard.d.ts @@ -116,6 +116,22 @@ declare namespace Api { } } + namespace HeadphoneImpedance { + interface Item { + id: number; + mac_addr: string; + device_model: string; + impedance_ohm: number; + headphone_brand: string; + headphone_model: string; + headphone_brand_norm?: string; + headphone_model_norm?: string; + ip_addr?: string; + create_at?: string | null; + update_at?: string | null; + } + } + namespace ShareCodeLog { interface Item { id: number; diff --git a/dashboard/frontend/src/typings/elegant-router.d.ts b/dashboard/frontend/src/typings/elegant-router.d.ts index 5f426f5..537b1c9 100644 --- a/dashboard/frontend/src/typings/elegant-router.d.ts +++ b/dashboard/frontend/src/typings/elegant-router.d.ts @@ -22,6 +22,7 @@ declare module "@elegant-router/types" { "500": "/500"; "headphone": "/headphone"; "headphone_brand": "/headphone/brand"; + "headphone_impedance": "/headphone/impedance"; "headphone_model": "/headphone/model"; "home": "/home"; "iframe-page": "/iframe-page/:url"; @@ -130,6 +131,7 @@ declare module "@elegant-router/types" { | "iframe-page" | "login" | "headphone_brand" + | "headphone_impedance" | "headphone_model" | "home" | "meilisearch" diff --git a/dashboard/frontend/src/views/headphone/impedance/index.vue b/dashboard/frontend/src/views/headphone/impedance/index.vue new file mode 100644 index 0000000..f913239 --- /dev/null +++ b/dashboard/frontend/src/views/headphone/impedance/index.vue @@ -0,0 +1,355 @@ + + +