diff --git a/server/src/controllers/misound.controller.js b/server/src/controllers/misound.controller.js index f422762..e96ff85 100644 --- a/server/src/controllers/misound.controller.js +++ b/server/src/controllers/misound.controller.js @@ -86,6 +86,21 @@ class MisoundController { } } + /** + * 从请求体中提取小爱音箱扩展播放配置 + * 空值保持为空,由渠道 validate 与 send 侧做默认处理 + */ + static _extractPlaybackConfig(body = {}) { + return { + startVolume: body.startVolume, + endVolume: body.endVolume, + playCount: body.playCount, + playInterval: body.playInterval, + audioUrl: body.audioUrl, + endVolumeDelay: body.endVolumeDelay, + }; + } + /** * 确认绑定并创建渠道 * @@ -96,6 +111,7 @@ class MisoundController { static async confirmBind(req, res) { try { const { userId, passToken, did, name, ttsMode } = req.body; + const playbackConfig = MisoundController._extractPlaybackConfig(req.body); if (!userId || !passToken) { return ResponseUtil.badRequest(res, '缺少登录凭证'); @@ -104,7 +120,7 @@ class MisoundController { return ResponseUtil.badRequest(res, '请输入设备名称'); } - // 创建渠道,配置中存储扫码获取的凭证 + // 创建渠道,配置中存储扫码获取的凭证与播放增强参数 const channel = await ChannelService.createChannel(req.user.userId, { channelType: 'misound', name: name || '小爱音箱', @@ -113,6 +129,7 @@ class MisoundController { passToken, did, ttsMode: ttsMode || 'auto', + ...playbackConfig, }, }); @@ -140,6 +157,7 @@ class MisoundController { try { const channelId = parseInt(req.params.channelId); const { userId, passToken, did, ttsMode } = req.body; + const playbackConfig = MisoundController._extractPlaybackConfig(req.body); if (!userId || !passToken) { return ResponseUtil.badRequest(res, '缺少登录凭证'); @@ -151,6 +169,7 @@ class MisoundController { passToken, did: did || '', ttsMode: ttsMode || 'auto', + ...playbackConfig, }, }); diff --git a/server/src/controllers/push.controller.js b/server/src/controllers/push.controller.js index 1d8ba06..97b23dc 100644 --- a/server/src/controllers/push.controller.js +++ b/server/src/controllers/push.controller.js @@ -7,6 +7,18 @@ const getRealIP = require('../utils/ip'); * 推送控制器 */ class PushController { + /** + * 从请求源中提取小爱音箱可选覆盖字段(仅 misound 渠道识别,其他渠道忽略) + */ + static _extractMisoundOverrides(source = {}) { + return { + volume: source.volume, + audioUrl: source.audioUrl, + playCount: source.playCount, + playInterval: source.playInterval, + }; + } + /** * 通过接口令牌推送 */ @@ -27,10 +39,18 @@ class PushController { // 支持 POST body 或 GET query 参数 const source = req.method === 'GET' ? req.query : req.body; - const { title, content, type = 'text', extraData } = source; + const { title, type = 'text', extraData } = source; + // content 可空:小爱音箱可仅传 audioUrl + const content = source.content == null ? '' : source.content; const url = source.url || ''; + const misoundOverrides = PushController._extractMisoundOverrides(source); - const result = await PushService.pushByToken(token, { title, content, type, url, extraData }, getRealIP(req), req.requestId); + const result = await PushService.pushByToken( + token, + { title, content, type, url, extraData, ...misoundOverrides }, + getRealIP(req), + req.requestId + ); if (result.success) { return ResponseUtil.success(res, result, '推送成功'); @@ -49,13 +69,15 @@ class PushController { static async pushByEndpoint(req, res) { try { const endpointId = parseInt(req.params.endpointId); - const { title, content, type = 'text', extraData } = req.body; + const { title, type = 'text', extraData } = req.body; + const content = req.body.content == null ? '' : req.body.content; const url = req.body.url || ''; + const misoundOverrides = PushController._extractMisoundOverrides(req.body); const result = await PushService.pushByEndpoint( endpointId, req.user.userId, - { title, content, type, url, extraData }, + { title, content, type, url, extraData, ...misoundOverrides }, getRealIP(req), req.requestId ); @@ -80,13 +102,15 @@ class PushController { static async pushByChannel(req, res) { try { const channelId = parseInt(req.params.channelId); - const { title, content, type = 'text', extraData } = req.body; + const { title, type = 'text', extraData } = req.body; + const content = req.body.content == null ? '' : req.body.content; const url = req.body.url || ''; + const misoundOverrides = PushController._extractMisoundOverrides(req.body); const result = await PushService.pushByChannel( channelId, req.user.userId, - { title, content, type, url, extraData }, + { title, content, type, url, extraData, ...misoundOverrides }, getRealIP(req), req.requestId ); diff --git a/server/src/middleware/validator.middleware.js b/server/src/middleware/validator.middleware.js index ed178b3..f9f8666 100644 --- a/server/src/middleware/validator.middleware.js +++ b/server/src/middleware/validator.middleware.js @@ -167,13 +167,21 @@ const pushMessageValidation = [ .isLength({ max: 200 }) .withMessage('标题不能超过200个字符'), + // content 默认可空:小爱音箱可仅用 audioUrl 推送;其他渠道仍建议传 content body('content') - .optional() - .trim() - .notEmpty() - .withMessage('消息内容不能为空') - .isLength({ max: 5000 }) - .withMessage('消息内容不能超过5000个字符'), + .optional({ nullable: true }) + .custom((value, { req }) => { + const content = value == null ? '' : String(value); + const source = req.method === 'GET' ? req.query : req.body; + const audioUrl = source && source.audioUrl; + if (!content.trim() && !audioUrl) { + throw new Error('消息内容不能为空(小爱音箱可仅传 audioUrl)'); + } + if (content.length > 5000) { + throw new Error('消息内容不能超过5000个字符'); + } + return true; + }), body('type') .optional() @@ -185,6 +193,24 @@ const pushMessageValidation = [ .isObject() .withMessage('extraData 必须是对象'), + // 小爱音箱可选覆盖字段 + body('volume') + .optional() + .isInt({ min: 0, max: 100 }) + .withMessage('volume 必须是 0-100 的整数'), + body('playCount') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('playCount 必须是 1-10 的整数'), + body('playInterval') + .optional() + .isFloat({ min: 0, max: 300 }) + .withMessage('playInterval 必须是 0-300 的数字'), + body('audioUrl') + .optional() + .isString() + .isLength({ max: 2000 }) + .withMessage('audioUrl 长度不能超过2000'), handleValidationErrors, ]; diff --git a/server/src/services/channels/misound.channel.js b/server/src/services/channels/misound.channel.js index 438f7f6..0d4003e 100644 --- a/server/src/services/channels/misound.channel.js +++ b/server/src/services/channels/misound.channel.js @@ -3,6 +3,17 @@ const logger = require('../../utils/logger'); let speakerModule = null; +/** 播放次数上限,避免推送请求长时间阻塞 */ +const MAX_PLAY_COUNT = 10; +/** 播放间隔上限(秒) */ +const MAX_PLAY_INTERVAL_SECONDS = 300; +/** 结束音量前默认短延迟(毫秒),用作无法估算时长时的兜底 */ +const DEFAULT_END_VOLUME_DELAY_MS = 1500; +/** 估算 TTS 语速(字符/秒),用于结束音量前等待播完 */ +const TTS_CHARS_PER_SECOND = 5; +/** 估算时长上限(毫秒),避免极端长文本导致长时间阻塞 */ +const ESTIMATED_DURATION_LIMIT_MS = 60 * 1000; + /** * 延迟加载 xiaoii speaker 模块(ESM 动态 import 兼容) */ @@ -13,13 +24,109 @@ function getSpeaker() { return speakerModule; } +/** + * 异步等待指定毫秒 + * @param {number} milliseconds + */ +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** + * 判断配置值是否视为「未设置」 + * @param {*} value + * @returns {boolean} + */ +function isEmptyConfigValue(value) { + if (value === null || value === undefined) return true; + if (typeof value === 'string' && value.trim() === '') return true; + return false; +} + +/** + * 规范化音量:空值返回 null;有值须为 0-100 整数 + * @param {*} value + * @param {string} fieldLabel + * @returns {{ ok: boolean, value?: number|null, message?: string }} + */ +function normalizeVolumeValue(value, fieldLabel) { + if (isEmptyConfigValue(value)) { + return { ok: true, value: null }; + } + const numericVolume = Number(typeof value === 'string' ? value.trim() : value); + // 允许 "90" 这类表单字符串,拒绝 90.5 等小数 + if (!Number.isFinite(numericVolume) || Math.floor(numericVolume) !== numericVolume) { + return { ok: false, message: `${fieldLabel}必须是 0-100 的整数` }; + } + if (numericVolume < 0 || numericVolume > 100) { + return { ok: false, message: `${fieldLabel}必须在 0-100 之间` }; + } + return { ok: true, value: numericVolume }; +} + +/** + * 规范化播放次数:空值默认 1;须为 1-MAX 整数 + * @param {*} value + * @returns {{ ok: boolean, value?: number, message?: string }} + */ +function normalizePlayCountValue(value) { + if (isEmptyConfigValue(value)) { + return { ok: true, value: 1 }; + } + const numericCount = Number(typeof value === 'string' ? value.trim() : value); + if (!Number.isFinite(numericCount) || Math.floor(numericCount) !== numericCount) { + return { ok: false, message: '播放次数必须是正整数' }; + } + if (numericCount < 1) { + return { ok: false, message: '播放次数至少为 1' }; + } + if (numericCount > MAX_PLAY_COUNT) { + return { ok: false, message: `播放次数不能超过 ${MAX_PLAY_COUNT}` }; + } + return { ok: true, value: numericCount }; +} + +/** + * 规范化播放间隔(秒):空值默认 0 + * @param {*} value + * @returns {{ ok: boolean, value?: number, message?: string }} + */ +function normalizePlayIntervalValue(value) { + if (isEmptyConfigValue(value)) { + return { ok: true, value: 0 }; + } + const numericInterval = Number(value); + if (!Number.isFinite(numericInterval) || numericInterval < 0) { + return { ok: false, message: '播放间隔必须是大于等于 0 的数字(单位:秒)' }; + } + if (numericInterval > MAX_PLAY_INTERVAL_SECONDS) { + return { ok: false, message: `播放间隔不能超过 ${MAX_PLAY_INTERVAL_SECONDS} 秒` }; + } + return { ok: true, value: numericInterval }; +} + +/** + * 规范化在线音频 URL:空值返回空串;有值须为 http(s) + * @param {*} value + * @returns {{ ok: boolean, value?: string, message?: string }} + */ +function normalizeAudioUrlValue(value) { + if (isEmptyConfigValue(value)) { + return { ok: true, value: '' }; + } + const audioUrl = String(value).trim(); + if (!/^https?:\/\//i.test(audioUrl)) { + return { ok: false, message: '在线音频地址必须以 http:// 或 https:// 开头' }; + } + return { ok: true, value: audioUrl }; +} + /** * 小爱音箱(MiSound)渠道适配器 * - * 通过 xiaoii 底层 Speaker 模块直接调用 TTS 接口, - * 将文本消息发送至小爱音箱进行语音播报。 + * 通过 xiaoii 底层 Speaker 模块调用 TTS / 在线音频 / 音量接口。 + * 支持开始/结束音量、播放次数与间隔、在线音频优先于 TTS。 * - * 不对外暴露 Webhook 服务,纯代码内调用直连小米 IoT API。 * GitHub: https://github.com/xvhuan/xiaoi */ class MisoundChannel extends BaseChannel { @@ -27,9 +134,15 @@ class MisoundChannel extends BaseChannel { * @param {Object} config - 渠道配置 * @param {string} config.userId - 小米 ID(数字) * @param {string} [config.passToken] - passToken(推荐) - * @param {string} [config.password] - 密码(不推荐,可能被安全验证拦截) + * @param {string} [config.password] - 密码(不推荐) * @param {string} config.did - 音箱设备标识或名称 - * @param {string} [config.ttsMode] - TTS 模式:auto(默认)/command/default + * @param {string} [config.ttsMode] - TTS 模式:auto/command/default + * @param {number|string|null} [config.startVolume] - 开始音量 0-100,空=不调 + * @param {number|string|null} [config.endVolume] - 结束音量 0-100,空=不调 + * @param {number|string} [config.playCount] - 播放次数,默认 1 + * @param {number|string} [config.playInterval] - 播放间隔(秒),默认 0 + * @param {number|string} [config.endVolumeDelay] - 结束音量前等待秒数,空=自动估算 + * @param {string} [config.audioUrl] - 在线音频 URL,有值则播音频而非 TTS * @param {number} channelId - 渠道记录 ID */ constructor(config, channelId) { @@ -39,6 +152,12 @@ class MisoundChannel extends BaseChannel { this.password = config.password || ''; this.did = config.did || ''; this.ttsMode = config.ttsMode || 'auto'; + this.startVolume = config.startVolume; + this.endVolume = config.endVolume; + this.playCount = config.playCount; + this.playInterval = config.playInterval; + this.audioUrl = config.audioUrl || ''; + this.endVolumeDelay = config.endVolumeDelay; this.channelId = channelId; this._initialized = false; } @@ -47,17 +166,17 @@ class MisoundChannel extends BaseChannel { * 构建 speaker 配置对象 */ _buildSpeakerConfig() { - const cfg = { + const speakerConfig = { userId: this.userId, did: this.did, ttsMode: this.ttsMode, }; if (this.passToken) { - cfg.passToken = this.passToken; + speakerConfig.passToken = this.passToken; } else if (this.password) { - cfg.password = this.password; + speakerConfig.password = this.password; } - return cfg; + return speakerConfig; } /** @@ -71,45 +190,239 @@ class MisoundChannel extends BaseChannel { logger.info(`Misound 初始化完成: did=${this.did}, ttsMode=${this.ttsMode}`); } - async send(message) { - const { title, content, type = 'text' } = message; + /** + * 规范化结束音量延迟(秒):空值返回 null(自动估算) + */ + static _normalizeEndVolumeDelay(value) { + if (isEmptyConfigValue(value)) { + return { ok: true, value: null }; + } + const numericDelay = Number(value); + if (!Number.isFinite(numericDelay) || numericDelay < 0) { + return { ok: false, message: '结束音量延迟必须是大于等于 0 的数字(单位:秒)' }; + } + if (numericDelay > MAX_PLAY_INTERVAL_SECONDS) { + return { ok: false, message: `结束音量延迟不能超过 ${MAX_PLAY_INTERVAL_SECONDS} 秒` }; + } + return { ok: true, value: numericDelay }; + } + + /** + * 解析运行时播放参数 + * @param {Object} [messageOverrides] - 推送 body 中的覆盖字段(优先于渠道配置) + */ + _resolvePlaybackOptions(messageOverrides = {}) { + // 推送 body 覆盖优先于渠道配置;均为可选 + const rawStartVolume = messageOverrides.volume ?? this.startVolume; + const rawAudioUrl = messageOverrides.audioUrl ?? this.audioUrl; + const rawPlayCount = messageOverrides.playCount ?? this.playCount; + const rawPlayInterval = messageOverrides.playInterval ?? this.playInterval; + const rawEndVolume = this.endVolume; // 结束音量不开放单次覆盖,避免误用 + const rawEndVolumeDelay = this.endVolumeDelay; + + const startVolumeResult = normalizeVolumeValue(rawStartVolume, '开始音量'); + if (!startVolumeResult.ok) { + throw new Error(startVolumeResult.message); + } + const endVolumeResult = normalizeVolumeValue(rawEndVolume, '结束音量'); + if (!endVolumeResult.ok) { + throw new Error(endVolumeResult.message); + } + const playCountResult = normalizePlayCountValue(rawPlayCount); + if (!playCountResult.ok) { + throw new Error(playCountResult.message); + } + const playIntervalResult = normalizePlayIntervalValue(rawPlayInterval); + if (!playIntervalResult.ok) { + throw new Error(playIntervalResult.message); + } + const audioUrlResult = normalizeAudioUrlValue(rawAudioUrl); + if (!audioUrlResult.ok) { + throw new Error(audioUrlResult.message); + } + const endVolumeDelayResult = MisoundChannel._normalizeEndVolumeDelay(rawEndVolumeDelay); + if (!endVolumeDelayResult.ok) { + throw new Error(endVolumeDelayResult.message); + } - // 合并标题和内容为纯文本 + return { + startVolume: startVolumeResult.value, + endVolume: endVolumeResult.value, + playCount: playCountResult.value, + playIntervalSeconds: playIntervalResult.value, + audioUrl: audioUrlResult.value, + endVolumeDelaySeconds: endVolumeDelayResult.value, + }; + } + + /** + * 合并并清洗 TTS 文本 + * @param {Object} message + * @returns {string} + */ + _buildTtsText(message) { + const { title, content, type = 'text' } = message; let text = content || ''; if (title) { text = title + (text ? ',' + text : ''); } - - // 清洗 markdown/html 标签,TTS 只接受纯文本 if (type === 'markdown') { text = this._stripMarkdown(text); } if (type === 'html') { text = BaseChannel.stripHtmlTags(text); } - - // 截断过长的文本(小爱音箱 TTS 有长度限制) if (text.length > 500) { text = text.substring(0, 500); - logger.warn(`Misound 文本过长,已截断至 500 字符`); + logger.warn('Misound 文本过长,已截断至 500 字符'); } + return text; + } - await this._ensureInitialized(); + /** + * 设置音箱音量(失败仅记日志,不中断主流程) + * @param {Object} speaker + * @param {number} volume + * @param {string} phaseLabel + */ + async _setVolumeSafe(speaker, volume, phaseLabel) { + try { + logger.info(`Misound ${phaseLabel}音量: did=${this.did}, volume=${volume}`); + await speaker.setVolume(volume, { did: this.did }); + } catch (error) { + logger.warn(`Misound ${phaseLabel}音量失败(继续播放): ${error.message}`); + } + } - const speaker = getSpeaker(); + /** + * 估算单次播放时长(毫秒),用于结束音量前等待播完 + * TTS 按文本长度估算;音频无法预估,返回兜底值 + */ + _estimatePlayDurationMs(playbackOptions, ttsText) { + // 用户显式配置了延迟则直接使用 + if (playbackOptions.endVolumeDelaySeconds !== null) { + return Math.min(playbackOptions.endVolumeDelaySeconds * 1000, ESTIMATED_DURATION_LIMIT_MS); + } + if (playbackOptions.audioUrl) { + // 在线音频无法预估长度,使用默认延迟 + return DEFAULT_END_VOLUME_DELAY_MS; + } + const textLength = (ttsText || '').length; + if (textLength === 0) { + return DEFAULT_END_VOLUME_DELAY_MS; + } + const estimatedMs = Math.ceil(textLength / TTS_CHARS_PER_SECOND) * 1000 + 1000; + return Math.min(estimatedMs, ESTIMATED_DURATION_LIMIT_MS); + } + + /** + * 执行单次播放:有 audioUrl 则播音频,否则 TTS + * 音频播放失败时,若有文本则降级为 TTS + * @param {Object} speaker + * @param {{ audioUrl: string, text: string, allowFallback: boolean }} options + */ + async _playOnce(speaker, options) { + const { audioUrl, text, allowFallback } = options; + if (audioUrl) { + logger.info(`Misound 播放在线音频: did=${this.did}, url=${audioUrl}`); + try { + return await speaker.playAudio(audioUrl, { did: this.did }); + } catch (error) { + if (allowFallback && text) { + logger.warn(`Misound 在线音频播放失败,降级为 TTS: ${error.message}`); + return await speaker.tts(text, { did: this.did }); + } + throw error; + } + } + if (!text) { + throw new Error('消息内容为空,且未配置在线音频'); + } logger.info(`Misound 发送 TTS: did=${this.did}, 长度=${text.length}`); + return await speaker.tts(text, { did: this.did }); + } + + + /** + * 核心发送逻辑(不含认证重试) + * @param {Object} message + */ + async _sendInternal(message) { + // 提取推送 body 中的覆盖字段(仅 misound 识别,优先于渠道配置) + const messageOverrides = { + volume: message.volume, + audioUrl: message.audioUrl, + playCount: message.playCount, + playInterval: message.playInterval, + }; + const playbackOptions = this._resolvePlaybackOptions(messageOverrides); + const ttsText = this._buildTtsText(message); + const playMode = playbackOptions.audioUrl ? 'audio' : 'tts'; + + await this._ensureInitialized(); + const speaker = getSpeaker(); + + if (playbackOptions.startVolume !== null) { + await this._setVolumeSafe(speaker, playbackOptions.startVolume, '开始'); + } + + const playResults = []; + const intervalMilliseconds = playbackOptions.playIntervalSeconds * 1000; + + for (let playIndex = 0; playIndex < playbackOptions.playCount; playIndex += 1) { + const singleResult = await this._playOnce(speaker, { + audioUrl: playbackOptions.audioUrl, + text: ttsText, + allowFallback: true, + }); + playResults.push(singleResult); + + // 非最后一次且配置了间隔时等待 + if (playIndex < playbackOptions.playCount - 1 && intervalMilliseconds > 0) { + await sleep(intervalMilliseconds); + } + } + + if (playbackOptions.endVolume !== null) { + // 估算播放时长后等待,尽量在播完后再设结束音量 + const waitMs = this._estimatePlayDurationMs(playbackOptions, ttsText); + if (waitMs > 0) { + logger.info(`Misound 结束音量前等待 ${waitMs}ms(估算播放时长)`); + await sleep(waitMs); + } + await this._setVolumeSafe(speaker, playbackOptions.endVolume, '结束'); + } + return { + success: true, + mode: playMode, + playCount: playbackOptions.playCount, + playInterval: playbackOptions.playIntervalSeconds, + startVolume: playbackOptions.startVolume, + endVolume: playbackOptions.endVolume, + results: playResults, + }; + } + + /** + * 发送推送:支持音量编排、多次播放、在线音频优先 + */ + async send(message) { try { - const result = await speaker.tts(text, { did: this.did }); - return { success: true, result }; + return await this._sendInternal(message); } catch (error) { - // 初始化可能过期,重试一次 - if (error.message && (error.message.includes('认证') || error.message.includes('token') || error.message.includes('登录'))) { - logger.warn(`Misound 认证可能过期,重新初始化后重试`); + // 初始化可能过期,重置后重试一次 + const errorMessage = error && error.message ? String(error.message) : ''; + const looksLikeAuthError = + errorMessage.includes('认证') || + errorMessage.includes('token') || + errorMessage.includes('登录') || + errorMessage.includes('Token'); + + if (looksLikeAuthError) { + logger.warn('Misound 认证可能过期,重新初始化后重试'); this._initialized = false; - await this._ensureInitialized(); - const retryResult = await speaker.tts(text, { did: this.did }); - return { success: true, result: retryResult }; + return await this._sendInternal(message); } throw error; } @@ -135,22 +448,48 @@ class MisoundChannel extends BaseChannel { validate(config) { - if (!config.userId || config.userId.trim() === '') { + if (!config.userId || String(config.userId).trim() === '') { return { valid: false, message: '小米ID不能为空' }; } - if (!/^\d+$/.test(config.userId.trim())) { + if (!/^\d+$/.test(String(config.userId).trim())) { return { valid: false, message: '小米ID必须为数字' }; } if (!config.passToken && !config.password) { return { valid: false, message: 'passToken 和密码至少需要填写一个' }; } - if (!config.did || config.did.trim() === '') { + if (!config.did || String(config.did).trim() === '') { return { valid: false, message: '音箱设备标识不能为空' }; } const validModes = ['auto', 'command', 'default']; if (config.ttsMode && !validModes.includes(config.ttsMode)) { return { valid: false, message: 'TTS模式必须是 auto、command 或 default' }; } + + const startVolumeResult = normalizeVolumeValue(config.startVolume, '开始音量'); + if (!startVolumeResult.ok) { + return { valid: false, message: startVolumeResult.message }; + } + const endVolumeResult = normalizeVolumeValue(config.endVolume, '结束音量'); + if (!endVolumeResult.ok) { + return { valid: false, message: endVolumeResult.message }; + } + const playCountResult = normalizePlayCountValue(config.playCount); + if (!playCountResult.ok) { + return { valid: false, message: playCountResult.message }; + } + const playIntervalResult = normalizePlayIntervalValue(config.playInterval); + if (!playIntervalResult.ok) { + return { valid: false, message: playIntervalResult.message }; + } + const audioUrlResult = normalizeAudioUrlValue(config.audioUrl); + if (!audioUrlResult.ok) { + return { valid: false, message: audioUrlResult.message }; + } + const endVolumeDelayResult = MisoundChannel._normalizeEndVolumeDelay(config.endVolumeDelay); + if (!endVolumeDelayResult.ok) { + return { valid: false, message: endVolumeDelayResult.message }; + } + return { valid: true, message: '' }; } @@ -221,6 +560,54 @@ class MisoundChannel extends BaseChannel { ], description: 'auto=智能选择最优方式; command=仅用MiOT指令; default=仅用MiNA默认链路', }, + { + name: 'startVolume', + label: '开始音量', + type: 'number', + required: false, + placeholder: '0-100,留空表示不调节', + description: '播报前设置的音量(0-100)。留空则不修改音箱当前音量', + }, + { + name: 'endVolume', + label: '结束音量', + type: 'number', + required: false, + placeholder: '0-100,留空表示不调节', + description: '播报结束后设置的音量(0-100)。留空则不修改。因音箱为异步下发指令,可能略早于播完生效', + }, + { + name: 'playCount', + label: '播放次数', + type: 'number', + required: false, + placeholder: '默认 1,最大 10', + description: '同一条消息重复播放的次数,默认 1,最大 10', + }, + { + name: 'playInterval', + label: '播放间隔(秒)', + type: 'number', + required: false, + placeholder: '默认 0', + description: '多次播放时,两次之间的等待秒数。请按文案/音频长度自行估算', + }, + { + name: 'audioUrl', + label: '在线音频 URL', + type: 'text', + required: false, + placeholder: 'https://example.com/alert.mp3', + description: '填写后优先播放该音频,不再播报推送文本。须为公网可访问的 http(s) 直链', + }, + { + name: 'endVolumeDelay', + label: '结束音量延迟(秒)', + type: 'number', + required: false, + placeholder: '留空=自动估算', + description: '设置结束音量前的等待秒数。留空则按 TTS 文本长度自动估算;音频无法预估,建议手动指定', + }, { name: '_docLinks', label: '相关文档', diff --git a/web/src/components/MisoundBindDialog.vue b/web/src/components/MisoundBindDialog.vue index 452e254..ca5d365 100644 --- a/web/src/components/MisoundBindDialog.vue +++ b/web/src/components/MisoundBindDialog.vue @@ -2,7 +2,7 @@ + + + +

+ 播报前设置的音量。留空则不修改音箱当前音量 +

+
+ + + +

+ 播报结束后设置的音量。留空则不修改 +

+
+ + + +

+ 同一条消息重复播放的次数,默认 1 +

+
+ + + +

+ 多次播放时两次之间的等待秒数 +

+
+ + + +

+ 填写后优先播放该音频,不再播报推送文本;须为公网 http(s) 直链 +

+
+ + + +

+ 设置结束音量前的等待秒数。留空则按 TTS 文本长度自动估算 +

+
@@ -231,6 +306,13 @@ const credentials = ref({}) const deviceName = ref('') const channelName = ref('小爱音箱') const ttsMode = ref('auto') +// 播放增强配置(均为可选) +const startVolume = ref('') +const endVolume = ref('') +const playCount = ref('1') +const playInterval = ref('0') +const audioUrl = ref('') +const endVolumeDelay = ref('') const binding = ref(false) const boundDeviceName = ref('') @@ -403,6 +485,24 @@ async function doPoll() { } } +/** + * 组装可选播放配置;空字符串转为 undefined,避免后端写入空串干扰校验时仍可留空 + */ +function buildPlaybackPayload() { + const optionalNumberOrUndefined = (rawValue) => { + const trimmed = String(rawValue ?? '').trim() + return trimmed === '' ? undefined : trimmed + } + return { + startVolume: optionalNumberOrUndefined(startVolume.value), + endVolume: optionalNumberOrUndefined(endVolume.value), + playCount: optionalNumberOrUndefined(playCount.value) ?? '1', + playInterval: optionalNumberOrUndefined(playInterval.value) ?? '0', + audioUrl: String(audioUrl.value || '').trim() || undefined, + endVolumeDelay: optionalNumberOrUndefined(endVolumeDelay.value), + } +} + /** * 确认绑定 */ @@ -424,6 +524,7 @@ async function handleConfirm() { passToken: credentials.value.passToken, did, ttsMode: ttsMode.value, + ...buildPlaybackPayload(), }) } else { res = await confirmMiBind({ @@ -432,6 +533,7 @@ async function handleConfirm() { did, name: channelName.value || '小爱音箱', ttsMode: ttsMode.value, + ...buildPlaybackPayload(), }) } @@ -476,6 +578,12 @@ function cleanup() { deviceName.value = '' channelName.value = '小爱音箱' ttsMode.value = 'auto' + startVolume.value = '' + endVolume.value = '' + playCount.value = '1' + playInterval.value = '0' + audioUrl.value = '' + endVolumeDelay.value = '' binding.value = false boundDeviceName.value = '' errorMsg.value = '' diff --git a/web/src/views/channels/List.vue b/web/src/views/channels/List.vue index b6b4489..f4ff2a4 100644 --- a/web/src/views/channels/List.vue +++ b/web/src/views/channels/List.vue @@ -12,29 +12,38 @@ +
+
-
+
-
-

{{ channel.name }}

-

+

+
+

{{ channel.name }}

+ + + {{ getMisoundBadge(channel) }} + +
+

{{ getChannelTypeName(channel.channel_type) }}

+ @@ -74,15 +83,25 @@
- -
+ +
- {{ key }}: - {{ value }} + {{ item.key }} + : + {{ item.value }}
@@ -101,8 +120,8 @@
- -
+ +
{{ channel.is_active ? '已启用' : '已禁用' }} @@ -562,11 +581,33 @@ const getDisplayConfig = (channel) => { } return displayConfig } + // 小爱音箱:精简展示,避免把所有扩展字段铺满卡片 + if (channel.channel_type === 'misound') { + const config = channel.config || {} + if (config.userId) displayConfig['小米ID'] = config.userId + if (config.passToken) displayConfig['PassToken'] = '********' + if (config.did) displayConfig['设备'] = config.did + if (config.ttsMode) displayConfig['TTS'] = config.ttsMode + if (config.startVolume !== undefined && config.startVolume !== '' && config.startVolume !== null) { + displayConfig['开始音量'] = config.startVolume + } + if (config.endVolume !== undefined && config.endVolume !== '' && config.endVolume !== null) { + displayConfig['结束音量'] = config.endVolume + } + const playCount = Number(config.playCount) || 1 + if (playCount > 1) displayConfig['播放次数'] = playCount + const playInterval = Number(config.playInterval) || 0 + if (playInterval > 0) displayConfig['间隔(秒)'] = playInterval + if (config.audioUrl) displayConfig['音频'] = config.audioUrl + return displayConfig + } const type = channelTypes.value.find(t => t.type === channel.channel_type) if (type) { type.configFields.forEach(field => { + // 跳过提示/链接等非展示字段 + if (field.type === 'hint' || field.type === 'links' || field.name?.startsWith('_')) return const value = channel.config[field.name] - if (value) { + if (value !== undefined && value !== null && value !== '') { displayConfig[field.label] = field.type === 'password' ? '********' : value } }) @@ -574,6 +615,14 @@ const getDisplayConfig = (channel) => { return displayConfig } +/** + * 将展示配置转为有序数组,便于双列布局与右对齐 + */ +const getDisplayConfigEntries = (channel) => { + const displayConfig = getDisplayConfig(channel) + return Object.entries(displayConfig).map(([key, value]) => ({ key, value })) +} + const getClawbotQuota = (channel) => { const config = channel.config if (!config.lastUserMsgTime) return null @@ -586,6 +635,25 @@ const getClawbotQuota = (channel) => { return { sendCount: config.sendCount || 0, remainText } } +/** + * 获取小爱音箱渠道的播放模式标签 + * 显示「音频」或「TTS」,多次播放时附带次数,配置了开始音量也一并展示 + */ +const getMisoundBadge = (channel) => { + const config = channel.config + if (!config) return '' + const parts = [] + parts.push(config.audioUrl ? '音频' : 'TTS') + const count = Number(config.playCount) || 1 + if (count > 1) { + parts.push(`×${count}`) + } + if (config.startVolume !== undefined && config.startVolume !== '' && config.startVolume !== null) { + parts.push(`音量${config.startVolume}`) + } + return parts.join(' · ') +} + const loadData = async () => { try { const [channelsRes, typesRes] = await Promise.all([