modex/index.vue 分割

This commit is contained in:
eafonyang
2026-07-01 16:53:38 +08:00
parent cacee5f294
commit e2577ff661
10 changed files with 1999 additions and 1157 deletions
+93 -3
View File
@@ -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}`);
+321
View File
@@ -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.txtDIR 通常为 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<Array>}
*/
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<string|null>} 如 '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<Array>}
*/
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,
};