From 17994445e21745b1807dff320d570a621342a088 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sat, 11 Jul 2026 11:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E7=9B=91=E6=8E=A7=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=8A=A5=20(=E6=92=AD?= =?UTF-8?q?=E6=8A=A5=E4=B8=AA=E8=82=A1=E5=90=8D=E7=A7=B0=E4=B8=8E=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7)=20v0.1.84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 voiceBroadcast.ts: 浏览器原生 speechSynthesis, 零依赖/零后端改动 - 按 source 分类拼接文案, 必念个股名称不念代码, 含涨跌幅 · strategy: [名称] 进入/移出 策略「名」 [涨跌幅] · signal: [名称] 买入/卖出信号触发 [涨跌幅] · price/market: [名称] [条件摘要] [涨跌幅] - 默认偏好 Google 中国大陆语音 (音质最佳), 异步注入自动同步回显 - 整批合并一句 + 正在播报时丢弃新批次 (防快行情叠噪音) - AlertToast 加 dispatchSideEffects 聚合点: 弹窗逻辑与副作用职责分离 - System 设置页加语音播报区: 开关(默认关)/音色/语速/试听 - 版本号同步升至 0.1.84 (VERSION / __version__ / package.json) - README 监控中心加语音播报特性说明 --- README.md | 4 +- VERSION | 2 +- backend/app/__init__.py | 2 +- frontend/package.json | 2 +- frontend/src/components/AlertToast.tsx | 9 +- frontend/src/lib/voiceBroadcast.ts | 212 +++++++++++++++++++++++++ frontend/src/pages/settings/System.tsx | 127 ++++++++++++++- 7 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/voiceBroadcast.ts diff --git a/README.md b/README.md index 2a333a3..4c3e830 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ | 🔍 **选股引擎** | 18 个内置策略 + 自定义信号 + AI 生成 + 代码迁移,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) | | 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) | | 🧪 **回测引擎** | 三种模式(个股/策略组合/自由信号),T+1/手续费/滑点/止损,SSE 流式进度 | [features.md](./docs/features.md) | -| 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 飞书推送 | [features.md](./docs/features.md) | +| 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 语音播报 + 飞书推送 | [features.md](./docs/features.md) | | 📈 **个股分析** | 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) | [features.md](./docs/features.md) | | 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘 + 炸板/翘板预警 | [features.md](./docs/features.md) | | 🧰 **数据扩展** | TickFlow 多源 + 第三方接入(接口/推送/CSV/JSON)同台分析 | [features.md](./docs/features.md) | @@ -84,7 +84,7 @@ - **连板梯队** Limit Up Ladder — 连板层级统计 + 概念/行业分布 + 封单监控(可切换连跌梯队) **🔔 监控与复盘** -- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,盘中实时弹窗 + 触发记录持久化 +- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化 - **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,可定时执行、推送飞书、下载 Markdown **🗄️ 数据与扩展** diff --git a/VERSION b/VERSION index a38cc5b..a783aed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.83 +v0.1.84 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index bc08968..342c02a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.83" +__version__ = "0.1.84" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/frontend/package.json b/frontend/package.json index 081de6b..d105af6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.83", + "version": "0.1.84", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/AlertToast.tsx b/frontend/src/components/AlertToast.tsx index c511976..d37997d 100644 --- a/frontend/src/components/AlertToast.tsx +++ b/frontend/src/components/AlertToast.tsx @@ -6,6 +6,13 @@ import type { AlertEvent } from '@/lib/api' import { fmtPct, fmtPrice } from '@/lib/format' import { cn } from '@/lib/cn' import { playNotificationSound } from '@/lib/notificationSound' +import { speakAlerts } from '@/lib/voiceBroadcast' + +/** 通知渠道分发 — 所有副作用渠道在此汇合, 新增渠道只改这里 */ +function dispatchSideEffects(alerts: AlertEvent[]) { + playNotificationSound() // 提示音 (Web Audio 合成) + speakAlerts(alerts) // 语音播报 (speechSynthesis, 各自独立开关) +} // ===== 全局状态 (模块级, 仿 Toast.tsx 模式) ===== type Item = { id: number; alert: AlertEvent } @@ -60,7 +67,7 @@ export function pushAlertToasts(alerts: AlertEvent[]) { for (const item of newItems) { setTimeout(() => dismiss(item.id), AUTO_DISMISS) } - playNotificationSound() // 整批只响一声 + dispatchSideEffects(alerts) // 副作用分发: 提示音 + 语音 (整批各一次) } /** 手动关闭 */ diff --git a/frontend/src/lib/voiceBroadcast.ts b/frontend/src/lib/voiceBroadcast.ts new file mode 100644 index 0000000..e586de8 --- /dev/null +++ b/frontend/src/lib/voiceBroadcast.ts @@ -0,0 +1,212 @@ +/** + * 语音播报 — 用浏览器原生 speechSynthesis, 无需依赖/音频文件。 + * + * 与 notificationSound.ts 平行的独立模块: + * - 引擎独立: speechSynthesis (OS 层) vs Web Audio + * - 配置独立: voice_broadcast_* localStorage keys + * - 开关独立: voice_broadcast_enabled (默认关) + * + * 节流策略 (复用通知声效"整批一声"理念, 语音更严): + * - speakAlerts() 一次接收一批告警, 合并成一句话 + * - 正在播报时直接丢弃新批次 (快行情下宁可漏念, 不叠成噪音) + */ + +import type { AlertEvent } from './api' + +const LS = { + enabled: 'voice_broadcast_enabled', // '1'/'0', 默认关 + voice: 'voice_broadcast_voice', // 语音包 voiceURI (空=系统默认) + rate: 'voice_broadcast_rate', // 语速 0.5-2, 默认 1 +} as const + +// ===== 激活态: 浏览器自动播放策略要求用户先交互一次 ===== +let _activated = false + +/** + * 解锁 speechSynthesis — 浏览器禁止页面加载后自动发声。 + * 由设置页开关/试听按钮点击时调用一次, 之后 SSE 推来的告警才能自动念。 + */ +export function activateVoice() { + try { + if (!_activated && 'speechSynthesis' in window) { + _activated = true + // 空语句触发激活, 不实际发声 + const u = new SpeechSynthesisUtterance('') + u.volume = 0 + window.speechSynthesis.speak(u) + } + } catch { /* ignore */ } +} + +/** 是否支持语音播报 */ +export function isVoiceSupported(): boolean { + return typeof window !== 'undefined' && 'speechSynthesis' in window +} + +// ===== 中文语音包检测 (供设置页下拉) ===== + +/** + * 返回系统可用的中文语音包。 + * 注意: getVoices() 首次调用可能返回空, 需监听 voiceschanged 事件。 + */ +export function listZhVoices(): SpeechSynthesisVoice[] { + try { + return window.speechSynthesis.getVoices().filter(v => v.lang.startsWith('zh')) + } catch { return [] } +} + +// ===== 语音包解析: 用户手选 > 默认偏好(Google 中国大陆) > 兜底 ===== + +/** + * 解析当前应使用的语音包。 + * 优先级: + * 1. 用户在设置页手选的 (voice_broadcast_voice) + * 2. 默认偏好: Google 中国大陆 (Chrome 在线云语音, zh-CN, 音质接近真人) + * 3. 兜底: 任意 zh-CN + * 4. 都没有: undefined (交浏览器系统默认, 可能不标准但不崩) + */ +function resolveVoice(): SpeechSynthesisVoice | undefined { + try { + const voices = window.speechSynthesis.getVoices() + if (voices.length === 0) return undefined + + // 1. 用户手选 + const configured = localStorage.getItem(LS.voice) + if (configured) { + const m = voices.find(v => v.voiceURI === configured) + if (m) return m + } + + // 2. 默认偏好: Google 中国大陆 + const googleCN = voices.find(v => /Google/i.test(v.name) && v.lang === 'zh-CN') + if (googleCN) return googleCN + + // 3. 兜底: 任意 zh-CN + return voices.find(v => v.lang === 'zh-CN') + } catch { return undefined } +} + +/** 当前实际使用的语音 voiceURI (供设置页下拉回显) */ +export function getCurrentVoiceURI(): string { + return resolveVoice()?.voiceURI ?? '' +} + +// ===== 文案拼接: 按 source 分类, 只念名称不念代码 ===== + +const MAX_SPEAK = 3 // 单批最多逐条念 3 只, 超出汇总成数量 + +/** 涨跌幅用中文习惯念: 5.2% → 涨5.2%, -3.1% → 跌3.1% */ +function fmtPctText(pct: number): string { + if (pct >= 0) return `涨${pct.toFixed(1)}%` + return `跌${Math.abs(pct).toFixed(1)}%` +} + +/** + * 单条告警 → 播报文案。 + * 设计原则: 必念个股名称 (不念代码), source 分类拼接: + * strategy(≤5只单条): "[名称] 进入/移出 策略「策略名」 [涨跌幅]" + * strategy(>5只批量): 直接念 message (后端已含名称列表) + * signal: "[名称] 买入/卖出信号触发 [涨跌幅]" + * price/market/其他: "[名称] [message条件摘要] [涨跌幅]" + */ +function buildSingleText(a: AlertEvent): string { + const name = a.name || '标的' + const pctText = a.change_pct != null ? fmtPctText(a.change_pct) : '' + + // 策略类: message 存的是策略名(单条) 或完整批量描述(>5只) + if (a.source === 'strategy') { + // 批量事件 (symbol 为空/为 _batch): message 已含 "策略「X」进入 N 只:…" 直接念 + if (!a.symbol || a.symbol === '_batch') { + return a.message || name + } + // 单条事件: 从 message 提取策略名 (格式 "策略「X」" 或直接是策略名) + const sm = a.message?.match(/策略「([^」]+)」/) + const sname = sm ? sm[1] : a.message || '' + const action = a.type === 'new_entry' ? '进入' : a.type === 'dropped' ? '移出' : '' + const parts = [name] + if (action) parts.push(action) + if (sname) parts.push(`策略「${sname}」`) + if (pctText) parts.push(pctText) + return parts.join(' ') + } + + // 信号类: message 形如 "买入信号触发"/"卖出信号触发" + if (a.source === 'signal') { + const parts = [name] + if (a.message) parts.push(a.message) + if (pctText) parts.push(pctText) + return parts.join(' ') + } + + // 价格/异动/其他: message 是条件摘要 (如 "现价 ≥ 100 · 涨幅 5%") + const parts = [name] + if (a.message) parts.push(a.message) + if (pctText) parts.push(pctText) + return parts.join(' ') +} + +function buildText(alerts: AlertEvent[]): string { + const head = alerts.slice(0, MAX_SPEAK) + const parts = head.map(buildSingleText) + let text = parts.join(';') + if (alerts.length > MAX_SPEAK) { + text += `;还有${alerts.length - MAX_SPEAK}只` + } + return text +} + +// ===== 节流: 正在念时丢弃新批次, 避免快行情叠加噪音 ===== +let _speaking = false + +/** + * 播报一批监控告警 (从 localStorage 读配置)。 + * 整批合并成一句话; 正在播报时丢弃新批次 (与"整批一声"理念一致)。 + */ +export function speakAlerts(alerts: AlertEvent[]) { + try { + if (alerts.length === 0) return + if (localStorage.getItem(LS.enabled) !== '1') return // 开关关: 不播报 + if (!isVoiceSupported()) return // 不支持: 静默 + if (_speaking) return // 正在念: 丢弃新批次 + + const text = buildText(alerts) + const u = new SpeechSynthesisUtterance(text) + u.lang = 'zh-CN' + u.rate = parseFloat(localStorage.getItem(LS.rate) || '1') + + const v = resolveVoice() + if (v) u.voice = v + + _speaking = true + u.onend = () => { _speaking = false } + u.onerror = () => { _speaking = false } + window.speechSynthesis.speak(u) + } catch { + // 语音不可用时静默 + } +} + +/** 停止当前播报 (关闭开关/试听前调用) */ +export function stopVoice() { + try { + if (isVoiceSupported()) { + window.speechSynthesis.cancel() + _speaking = false + } + } catch { /* ignore */ } +} + +/** 试听 (设置页点"试听"用) */ +export function previewVoice(text = '语音播报已开启, 这是试听效果') { + try { + if (!isVoiceSupported()) return + activateVoice() + const u = new SpeechSynthesisUtterance(text) + u.lang = 'zh-CN' + u.rate = parseFloat(localStorage.getItem(LS.rate) || '1') + const v = resolveVoice() + if (v) u.voice = v + window.speechSynthesis.cancel() // 试听前停掉正在念的 + window.speechSynthesis.speak(u) + } catch { /* ignore */ } +} diff --git a/frontend/src/pages/settings/System.tsx b/frontend/src/pages/settings/System.tsx index 6edaa8a..54b5e75 100644 --- a/frontend/src/pages/settings/System.tsx +++ b/frontend/src/pages/settings/System.tsx @@ -3,7 +3,7 @@ * * 独立于实时监控, 放置影响整体应用行为的开关项。 */ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react' import { usePreferences, useVersion } from '@/lib/useSharedQueries' @@ -12,6 +12,9 @@ import { QK } from '@/lib/queryKeys' import { PageHeader } from '@/components/PageHeader' import { refreshAlertToastConfig } from '@/components/AlertToast' import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound' +import { + listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI, +} from '@/lib/voiceBroadcast' export function SettingsSystemPanel() { const qc = useQueryClient() @@ -36,6 +39,41 @@ export function SettingsSystemPanel() { const [soundType, setSoundType] = useState(() => { try { return localStorage.getItem('alert_sound') || 'ding' } catch { return 'ding' } }) + const [voiceEnabled, setVoiceEnabled] = useState(() => { + try { return localStorage.getItem('voice_broadcast_enabled') === '1' } catch { return false } + }) + const [voices, setVoices] = useState(listZhVoices()) + // 用户手选值 (空=走默认偏好 Google 中国大陆) + const [voiceConfigured, setVoiceConfigured] = useState(() => { + try { return localStorage.getItem('voice_broadcast_voice') || '' } catch { return '' } + }) + // 下拉回显值: 用户手选优先, 否则显示当前解析到的语音 + const [voiceURI, setVoiceURI] = useState(() => { + try { return localStorage.getItem('voice_broadcast_voice') || getCurrentVoiceURI() } catch { return getCurrentVoiceURI() } + }) + const [voiceRate, setVoiceRate] = useState(() => { + try { + const v = parseFloat(localStorage.getItem('voice_broadcast_rate') || '') + return v >= 0.5 && v <= 2 ? v : 1 + } catch { return 1 } + }) + + // 语音包异步加载 (Google 云语音为 Chrome 联网注入, 比本地晚到), 监听刷新 + useEffect(() => { + const h = () => { + setVoices(listZhVoices()) + // 用户未手选时, 跟随默认偏好 (Google CN 到货后自动同步回显) + if (!voiceConfigured) setVoiceURI(getCurrentVoiceURI()) + } + if ('speechSynthesis' in window) { + window.speechSynthesis.addEventListener('voiceschanged', h) + // 部分浏览器首次需主动触发一次 + h() + } + return () => { + if ('speechSynthesis' in window) window.speechSynthesis.removeEventListener('voiceschanged', h) + } + }, [voiceConfigured]) const save = useCallback(async (cfg: Record) => { setSaving(true) @@ -163,6 +201,93 @@ export function SettingsSystemPanel() { +
+
+ +

语音播报

+
+ + { + localStorage.setItem('voice_broadcast_enabled', v ? '1' : '0') + setVoiceEnabled(v) + if (v) { activateVoice(); previewVoice() } // 开启即激活 + 试听一句 + }} + /> + +
+
+ +
+
语音音色
+
+ {voices.length === 0 + ? '未检测到中文语音, 将用系统默认' + : '默认优先 Google 中国大陆 (音质最佳)'} +
+
+
+
+ + +
+
+ +
+
+
语速
+
0.5 慢 — 2.0 快
+
+
+ { + const v = parseFloat(e.target.value) + localStorage.setItem('voice_broadcast_rate', String(v)) + setVoiceRate(v) + }} + className="w-32 disabled:opacity-50" + /> + {voiceRate.toFixed(1)} +
+
+
+