diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js index 0354761..7380204 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.js @@ -13,6 +13,7 @@ const { authMiddleware } = require('../middleware/auth'); const { fetchAndValidateCurve } = require('../services/curveClient'); const { uploadMeasurementToS3, getMeasurementFromS3 } = require('../services/measurementStorage'); const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage'); +const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink'); /** * 将 TXT 频响文件内容转换为 CSV 格式 @@ -303,6 +304,70 @@ router.get('/api/models/:model_id', async (req, res) => { } }); +// POST /api/models/squiglink-fetch — 从 squig.link share URL 抓取频响数据 +router.post('/api/models/squiglink-fetch', async (req, res) => { + try { + const { share_url, selected_file } = req.body; + if (!share_url) { + return res.json(ApiResponse.error('缺少 share_url 参数')); + } + + logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`); + + // 如果用户从候选列表中选择了特定文件,直接下载 + if (selected_file) { + const { parseShareUrl, detectFormFromUrl } = require('../services/squiglink'); + const { baseUrl } = parseShareUrl(share_url); + const [downloadResult, detectedForm] = await Promise.all([ + downloadTxtFile(baseUrl, selected_file), + detectFormFromUrl(baseUrl), + ]); + const csvContent = convertTxtToCsv(downloadResult.buffer); + // 从 file 名解析品牌和型号 + const parts = selected_file.split(' '); + const brandName = parts.length > 1 ? parts[0] : ''; + const modelName = parts.length > 1 ? parts.slice(1).join(' ') : selected_file; + return res.json(ApiResponse.success({ + brand_name: brandName, + model_name: modelName, + form: detectedForm || null, + csv_content: csvContent, + data_url: downloadResult.url, + matches: [], + })); + } + + // 自动抓取 + const result = await fetchFromSquigLink(share_url); + + // 多个候选,返回给前端选择 + if (result.matches.length > 1) { + return res.json(ApiResponse.success({ + brand_name: '', + model_name: '', + form: result.form || null, + csv_content: null, + data_url: null, + matches: result.matches, + })); + } + + // 单个匹配,返回 CSV 内容 + const csvContent = convertTxtToCsv(result.buffer); + return res.json(ApiResponse.success({ + brand_name: result.brandName, + model_name: result.modelName, + form: result.form || null, + csv_content: csvContent, + data_url: result.dataUrl, + matches: [], + })); + } catch (e) { + logger.error(`SquigLink fetch error: ${e.message}`); + return res.json(ApiResponse.error(e.message || '抓取失败')); + } +}); + // POST /api/models/ (multipart/form-data) router.post('/api/models/', upload.single('measurement_file'), async (req, res) => { try { @@ -316,8 +381,19 @@ router.post('/api/models/', upload.single('measurement_file'), async (req, res) return res.json(ApiResponse.error('该品牌下型号名称已存在')); } - // 处理文件上传 - if (req.file && req.file.originalname) { + // 处理文件上传(优先 squiglink_csv,其次文件上传) + const { squiglink_csv } = req.body; + if (squiglink_csv) { + // 从 squig.link 抓取的 CSV 内容直接上传 S3 + const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); + const s3Key = await uploadMeasurementToS3(csvBuffer, { + source: source || 'Eafonyoung', + form: form || 'in-ear', + brandName: brand_name, + modelName: name, + }); + logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`); + } else if (req.file && req.file.originalname) { const fileExt = path.extname(req.file.originalname).toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(fileExt)) { logger.error(`Unsupported file format: ${fileExt}`); @@ -390,7 +466,21 @@ router.put('/api/models/:model_id', upload.single('measurement_file'), async (re const effSource = source === undefined || source === 'null' ? dbModel.source : source; const effForm = form === undefined || form === 'null' ? dbModel.form : form; - if (req.file && req.file.originalname) { + // 处理频响文件(优先 squiglink_csv,其次文件上传) + const { squiglink_csv } = req.body; + if (squiglink_csv) { + if (!effSource || !effForm) { + return res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); + } + const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); + const s3Key = await uploadMeasurementToS3(csvBuffer, { + source: effSource, + form: effForm, + brandName: newBrandName, + modelName: newName, + }); + logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`); + } else if (req.file && req.file.originalname) { const fileExt = path.extname(req.file.originalname).toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(fileExt)) { logger.error(`Unsupported file format: ${fileExt}`); diff --git a/backend/src/services/squiglink.js b/backend/src/services/squiglink.js new file mode 100644 index 0000000..4c0061a --- /dev/null +++ b/backend/src/services/squiglink.js @@ -0,0 +1,321 @@ +/** + * squig.link 频响数据抓取服务 + * + * 核心机制: + * - 每个 squig.link 实例都有 {baseURL}/data/phone_book.json 清单文件 + * - share URL 的 share 参数(下划线→空格)匹配 phone_book.json 中的 file 字段 + * - 数据 URL = {baseURL}/{DIR}/{file值} L.txt(DIR 通常为 data/) + */ +const axios = require('axios'); +const logger = require('../config/logger'); + +const FETCH_TIMEOUT = 15000; // 15s +const USER_AGENT = 'Dashboard/1.0 (Measurement Fetcher)'; +const SQUIGLINK_SITES_URL = 'https://squig.link/squigsites.json'; + +// squig.link 类型 → 系统佩戴方式映射 +const TYPE_TO_FORM_MAP = { + 'iems': 'in-ear', + 'headphones': 'over-ear', + 'earbuds': 'earbud', + '5128': 'over-ear', +}; + +// squigsites.json 内存缓存(TTL 1 小时) +let _squigsitesCache = null; +let _squigsitesCacheTime = 0; +const SQUIGLINK_SITES_TTL = 60 * 60 * 1000; // 1 hour + +/** + * 获取 squigsites.json(带内存缓存,1 小时内不重复请求) + * @returns {Promise} + */ +async function getSquigSites() { + const now = Date.now(); + if (_squigsitesCache && (now - _squigsitesCacheTime) < SQUIGLINK_SITES_TTL) { + return _squigsitesCache; + } + logger.info('Fetching squigsites.json (cache miss or expired)'); + const res = await axios.get(SQUIGLINK_SITES_URL, { + timeout: FETCH_TIMEOUT, + headers: { 'User-Agent': USER_AGENT }, + }); + if (Array.isArray(res.data)) { + _squigsitesCache = res.data; + _squigsitesCacheTime = now; + } + return res.data; +} + +/** + * 解析 share URL,提取 base URL 和 share 参数 + * 支持格式: + * https://theaudiostore.squig.link/?share=Nostalgia_Audio_Camelot + * https://squig.link/?share=Moondrop_Chu_2 + * @param {string} shareUrl + * @returns {{ baseUrl: string, shareParam: string }} + */ +function parseShareUrl(shareUrl) { + const url = new URL(shareUrl.trim()); + const shareParam = url.searchParams.get('share'); + if (!shareParam) { + throw new Error('URL 中缺少 share 参数'); + } + // base URL = origin(如 https://theaudiostore.squig.link) + const baseUrl = url.origin; + return { baseUrl, shareParam }; +} + +/** + * 从 share URL 自动检测佩戴方式 + * 通过 squigsites.json 查找实例配置中的 dbs.type 并映射 + * @param {string} baseUrl - 如 https://theaudiostore.squig.link + * @returns {Promise} 如 'in-ear' / 'over-ear' / 'earbud' / null + */ +async function detectFormFromUrl(baseUrl) { + try { + // 从 URL 提取 username(子域名) + const urlObj = new URL(baseUrl); + const hostname = urlObj.hostname; // e.g. theaudiostore.squig.link + const username = hostname.split('.')[0]; // e.g. theaudiostore + if (!username || username === 'squig') return null; + + // 获取 squigsites.json(带缓存) + logger.info(`Detecting form for: ${username}`); + const sites = await getSquigSites(); + if (!Array.isArray(sites)) return null; + + // 查找匹配的实例 + const site = sites.find((s) => s.username === username); + if (!site || !Array.isArray(site.dbs) || site.dbs.length === 0) return null; + + // 提取 URL 路径,匹配 db folder + const urlPath = urlObj.pathname || '/'; + let matchedType = null; + + for (const db of site.dbs) { + const folder = db.folder || '/'; + // 如果 URL 路径以 db.folder 开头,则匹配 + if (urlPath.startsWith(folder) || folder === '/') { + matchedType = db.type; + } + } + + // 如果只有一个 db,直接用它 + if (!matchedType && site.dbs.length === 1) { + matchedType = site.dbs[0].type; + } + + if (!matchedType) return null; + + const form = TYPE_TO_FORM_MAP[matchedType.toLowerCase()] || null; + if (form) { + logger.info(`Detected form from squigsites.json: ${username} → ${matchedType} → ${form}`); + } + return form; + } catch (e) { + logger.warn(`Failed to detect form from squigsites.json: ${e.message}`); + return null; + } +} + +/** + * 获取 squig.link 实例的 phone_book.json + * @param {string} baseUrl - 如 https://theaudiostore.squig.link + * @returns {Promise} + */ +async function fetchPhoneBook(baseUrl) { + const url = `${baseUrl}/data/phone_book.json`; + logger.info(`Fetching phone_book: ${url}`); + const res = await axios.get(url, { + timeout: FETCH_TIMEOUT, + headers: { 'User-Agent': USER_AGENT }, + // 忽略缓存参数(有些站点加 ?timestamp 后缀) + maxRedirects: 5, + }); + if (!Array.isArray(res.data)) { + throw new Error('phone_book.json 格式异常:期望数组'); + } + return res.data; +} + +/** + * 在 phone_book 中查找匹配的 file 字段值 + * @param {Array} phoneBook + * @param {string} shareParam - share 参数(下划线形式) + * @returns {{ fileName: string, brandName: string, modelName: string } | null} + */ +function findInPhoneBook(phoneBook, shareParam) { + // share 参数转空格形式 + const target = shareParam.replace(/_/g, ' ').trim(); + const targetLower = target.toLowerCase(); + + for (const brand of phoneBook) { + const brandName = brand.name || ''; + const phones = brand.phones || []; + for (const phone of phones) { + // file 字段可能是 string 或 string[] + const fileField = phone.file || phone.name; + const candidates = Array.isArray(fileField) ? fileField : [fileField]; + + for (const fileName of candidates) { + if (!fileName) continue; + if (fileName.toLowerCase() === targetLower) { + return { + fileName, + brandName, + modelName: phone.name || '', + }; + } + } + } + } + + return null; +} + +/** + * 模糊查找:当精确匹配失败时,用 share 参数关键词模糊搜索 + * @param {Array} phoneBook + * @param {string} shareParam + * @returns {Array<{ fileName: string, brandName: string, modelName: string }>} + */ +function fuzzyFindInPhoneBook(phoneBook, shareParam) { + const target = shareParam.replace(/_/g, ' ').trim().toLowerCase(); + const keywords = target.split(/\s+/); + const results = []; + + for (const brand of phoneBook) { + const brandName = brand.name || ''; + const phones = brand.phones || []; + for (const phone of phones) { + const fileField = phone.file || phone.name; + const candidates = Array.isArray(fileField) ? fileField : [fileField]; + + for (const fileName of candidates) { + if (!fileName) continue; + const fileLower = fileName.toLowerCase(); + // 所有关键词都出现在 fileName 中 + if (keywords.every((kw) => fileLower.includes(kw))) { + results.push({ fileName, brandName, modelName: phone.name || '' }); + } + } + } + } + + return results; +} + +/** + * 下载 TXT 频响文件 + * 按优先级尝试多种后缀:L声道 → R声道 → 无后缀 → 带编号样本 + * @param {string} baseUrl + * @param {string} fileName - phone_book 中的 file 值 + * @returns {Promise<{ buffer: Buffer, url: string }>} + */ +async function downloadTxtFile(baseUrl, fileName) { + // 按优先级尝试的后缀列表 + const suffixes = [ + ' L.txt', ' R.txt', '.txt', + ' L1.txt', ' R1.txt', + ' L2.txt', ' R2.txt', + ]; + let lastError = null; + + for (const suffix of suffixes) { + const url = `${baseUrl}/data/${encodeURIComponent(fileName + suffix)}`; + try { + logger.info(`Downloading TXT: ${url}`); + const res = await axios.get(url, { + timeout: FETCH_TIMEOUT, + responseType: 'arraybuffer', + headers: { 'User-Agent': USER_AGENT }, + }); + const buffer = Buffer.from(res.data); + if (buffer.length < 10) { + throw new Error('文件内容过短,可能无效'); + } + logger.info(`TXT downloaded: ${url} (${buffer.length} bytes)`); + return { buffer, url }; + } catch (e) { + lastError = e; + logger.warn(`TXT download failed (${suffix}): ${e.message}`); + } + } + + throw new Error(`无法下载频响文件(已尝试 ${suffixes.length} 种后缀):${lastError?.message || '未知错误'}`); +} + +/** + * 从 squig.link share URL 抓取频响数据 + * @param {string} shareUrl - 完整的 share URL + * @returns {Promise<{ + * buffer: Buffer, + * fileName: string, + * dataUrl: string, + * brandName: string, + * modelName: string, + * matches: Array<{ fileName: string, brandName: string, modelName: string }> + * }>} + */ +async function fetchFromSquigLink(shareUrl) { + const { baseUrl, shareParam } = parseShareUrl(shareUrl); + + // 并行获取 phone_book 和检测佩戴方式 + const [phoneBook, detectedForm] = await Promise.all([ + fetchPhoneBook(baseUrl).catch((e) => { + throw new Error(`获取 phone_book.json 失败:${e.message}`); + }), + detectFormFromUrl(baseUrl), + ]); + + // 精确匹配 + let match = findInPhoneBook(phoneBook, shareParam); + let matches = []; + + if (!match) { + // 模糊匹配 + matches = fuzzyFindInPhoneBook(phoneBook, shareParam); + if (matches.length === 0) { + throw new Error('在 phone_book.json 中未找到匹配的测量数据'); + } + if (matches.length === 1) { + match = matches[0]; + } else { + // 多个匹配,返回候选列表让前端选择 + return { + buffer: null, + fileName: null, + dataUrl: null, + brandName: '', + modelName: '', + form: detectedForm, + matches, + }; + } + } + + // 下载 TXT 文件 + const { buffer, url: dataUrl } = await downloadTxtFile(baseUrl, match.fileName); + + return { + buffer, + fileName: match.fileName, + dataUrl, + brandName: match.brandName, + modelName: match.modelName, + form: detectedForm, + matches: [match], + }; +} + +module.exports = { + parseShareUrl, + fetchFromSquigLink, + fetchPhoneBook, + findInPhoneBook, + fuzzyFindInPhoneBook, + downloadTxtFile, + detectFormFromUrl, + getSquigSites, +}; diff --git a/frontend/src/api/model.js b/frontend/src/api/model.js index 28e74bf..1ca129e 100644 --- a/frontend/src/api/model.js +++ b/frontend/src/api/model.js @@ -148,3 +148,17 @@ export function pushToMeilisearch(modelIds) { skipErrorToast: true }) } + +/** + * 从 squig.link share URL 抓取频响数据 + * @param {string} shareUrl - squig.link 分享链接 + * @param {string} [selectedFile] - 用户从候选列表选择的文件名 + */ +export function fetchFromSquigLink(shareUrl, selectedFile) { + return request({ + url: '/models/squiglink-fetch', + method: 'post', + data: { share_url: shareUrl, selected_file: selectedFile }, + timeout: 30000 + }) +} diff --git a/frontend/src/views/model/components/CsvViewerDialog.vue b/frontend/src/views/model/components/CsvViewerDialog.vue new file mode 100644 index 0000000..9279150 --- /dev/null +++ b/frontend/src/views/model/components/CsvViewerDialog.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/views/model/components/EqCacheDialog.vue b/frontend/src/views/model/components/EqCacheDialog.vue new file mode 100644 index 0000000..fc5896b --- /dev/null +++ b/frontend/src/views/model/components/EqCacheDialog.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/frontend/src/views/model/components/ModelFormDialog.vue b/frontend/src/views/model/components/ModelFormDialog.vue new file mode 100644 index 0000000..f43bff7 --- /dev/null +++ b/frontend/src/views/model/components/ModelFormDialog.vue @@ -0,0 +1,554 @@ + + + + + diff --git a/frontend/src/views/model/components/PushProgressDialog.vue b/frontend/src/views/model/components/PushProgressDialog.vue new file mode 100644 index 0000000..7b113a0 --- /dev/null +++ b/frontend/src/views/model/components/PushProgressDialog.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/views/model/components/PushViewDialog.vue b/frontend/src/views/model/components/PushViewDialog.vue new file mode 100644 index 0000000..fb5ab1c --- /dev/null +++ b/frontend/src/views/model/components/PushViewDialog.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/views/model/composables/useModelList.js b/frontend/src/views/model/composables/useModelList.js new file mode 100644 index 0000000..62cd5b6 --- /dev/null +++ b/frontend/src/views/model/composables/useModelList.js @@ -0,0 +1,190 @@ +import { ref, reactive, nextTick } from 'vue' +import { ElMessage } from 'element-plus' +import { getModels } from '@/api/model' +import { getBrands } from '@/api/brand' + +export function useModelList(tableRef) { + const loading = ref(false) + const tableData = ref([]) + const selectedRows = ref([]) + const selectedIds = ref([]) + const brandOptions = ref([]) + + /** 与接口一致:id | create_at */ + const sortBy = ref('id') + /** asc | desc */ + const sortOrder = ref('desc') + + const searchForm = reactive({ + brand_name: '', + name: '' + }) + + const pagination = reactive({ + page: 1, + pageSize: 50, + total: 0 + }) + + // ---- 数据加载 ---- + + const loadData = async () => { + loading.value = true + try { + const res = await getModels({ + skip: (pagination.page - 1) * pagination.pageSize, + limit: pagination.pageSize, + brand_name: searchForm.brand_name || undefined, + name: searchForm.name || undefined, + sort_by: sortBy.value, + sort_order: sortOrder.value + }) + + if (res.code === 1 && res.data) { + tableData.value = res.data.items || [] + pagination.total = res.data.total || 0 + } else if (res.code === 2) { + tableData.value = [] + pagination.total = 0 + } + } catch (error) { + console.error('加载数据失败:', error) + ElMessage.error('加载数据失败') + } finally { + loading.value = false + } + } + + const loadBrands = async () => { + try { + 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) + } + } + + // ---- 搜索 / 重置 ---- + + const handleSearch = () => { + pagination.page = 1 + loadData() + } + + const handleReset = () => { + searchForm.brand_name = '' + searchForm.name = '' + pagination.page = 1 + loadData() + } + + // ---- 排序 / 分页 ---- + + 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 handleSizeChange = () => { + loadData() + } + + const handlePageChange = () => { + loadData() + } + + // ---- 选择 ---- + + const handleSelectionChange = (selection) => { + selectedRows.value = selection + selectedIds.value = selection.map((row) => row.id) + } + + // ---- 复制 ---- + + const handleCopyName = (row) => { + const text = `${row.brand_name} ${row.name}` + navigator.clipboard.writeText(text).then( + () => ElMessage.success(`已复制: ${text}`), + () => ElMessage.error('复制失败') + ) + } + + const handleCopyBrand = (row) => { + const text = row.brand_name + navigator.clipboard.writeText(text).then( + () => ElMessage.success(`已复制: ${text}`), + () => ElMessage.error('复制失败') + ) + } + + const handleCopySelectedNames = () => { + if (selectedRows.value.length === 0) return + const text = selectedRows.value + .map((row) => `${row.brand_name} ${row.name}`) + .join('\n') + navigator.clipboard.writeText(text).then( + () => ElMessage.success(`已复制 ${selectedRows.value.length} 条`), + () => ElMessage.error('复制失败') + ) + } + + // ---- 格式化工具 ---- + + 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 || '-' + } + + return { + loading, + tableData, + selectedRows, + selectedIds, + brandOptions, + sortBy, + sortOrder, + searchForm, + pagination, + loadData, + loadBrands, + handleSearch, + handleReset, + handleSortChange, + handleSizeChange, + handlePageChange, + handleSelectionChange, + handleCopyName, + handleCopyBrand, + handleCopySelectedNames, + formatDateTime, + formatFormLabel + } +} diff --git a/frontend/src/views/model/index.vue b/frontend/src/views/model/index.vue index aa2f957..c415fdc 100644 --- a/frontend/src/views/model/index.vue +++ b/frontend/src/views/model/index.vue @@ -45,7 +45,7 @@
@@ -184,628 +184,151 @@ - - - - - - -
- 品牌「{{ (formData.brand_name || '').trim() }}」尚未在品牌库中,可继续填写并保存;保存新型号时将自动创建该品牌。 -
-
- - - - - - - - - - - - - - - - - - - -
- 拖拽文件到此处或点击上传 -
-
-
-
- -
+ - + -
- - 加载中… -
- - - {{ pushViewDocument.id }} - {{ pushViewDocument.brand_name }} - {{ pushViewDocument.name }} - {{ formatFormLabel(pushViewDocument.form) }} - {{ pushViewDocument.rig || '-' }} - {{ pushViewDocument.source || '-' }} - - -
+ :loading="pushViewLoading" + :pushed="pushViewPushed" + :document="pushViewDocument" + /> - + -
- - 加载中… -
-
- -

Redis Key: {{ eqCacheRedisKey }}

-
-
-

Redis Key: {{ eqCacheRedisKey }}

-

Hash Keys({{ eqCacheFieldKeys.length }})

-
    -
  • - -
    - -
    -
  • -
-
- -
+ :model-id="eqCacheModelId" + :model-label="eqCacheModelLabel" + /> - + -

{{ csvS3Key }}

- - -
+ :s3-key="csvS3Key" + :content="csvContent" + /> - + -
    -
  • -
    - 校验数据 -

    - 正在校验:{{ validatingCurrent.brand_name }} · {{ validatingCurrent.name }} - - ({{ validateProgress.index }}/{{ validateProgress.total }}) - -

    -
    - - - - - - - - - - - -
  • -
  • -
    - 推送 -

    - 正在推送:{{ pushingCurrent.brand_name }} · {{ pushingCurrent.name }} -

    -

    - 已推送 {{ pushResultCount }} 条 -

    -
    - - - - - - - - - - - -
  • -
-
-

校验未通过:

-
    -
  • - {{ item.brand_name }} {{ item.name }}:{{ formatValidateReason(item.reason) }} -
  • -
-
- -
+ />