feat(voice): 监控告警语音播报 (播报个股名称与信号) v0.1.84

- 新增 voiceBroadcast.ts: 浏览器原生 speechSynthesis, 零依赖/零后端改动
- 按 source 分类拼接文案, 必念个股名称不念代码, 含涨跌幅
  · strategy: [名称] 进入/移出 策略「名」 [涨跌幅]
  · signal:   [名称] 买入/卖出信号触发 [涨跌幅]
  · price/market: [名称] [条件摘要] [涨跌幅]
- 默认偏好 Google 中国大陆语音 (音质最佳), 异步注入自动同步回显
- 整批合并一句 + 正在播报时丢弃新批次 (防快行情叠噪音)
- AlertToast 加 dispatchSideEffects 聚合点: 弹窗逻辑与副作用职责分离
- System 设置页加语音播报区: 开关(默认关)/音色/语速/试听
- 版本号同步升至 0.1.84 (VERSION / __version__ / package.json)
- README 监控中心加语音播报特性说明
This commit is contained in:
shy3130
2026-07-11 11:21:57 +08:00
parent f1cc948eb2
commit 17994445e2
7 changed files with 351 additions and 7 deletions
+2 -2
View File
@@ -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
**🗄️ 数据与扩展**
+1 -1
View File
@@ -1 +1 @@
v0.1.83
v0.1.84
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.83"
__version__ = "0.1.84"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.83",
"version": "0.1.84",
"type": "module",
"scripts": {
"dev": "vite",
+8 -1
View File
@@ -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) // 副作用分发: 提示音 + 语音 (整批各一次)
}
/** 手动关闭 */
+212
View File
@@ -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 */ }
}
+126 -1
View File
@@ -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<string, unknown>) => {
setSaving(true)
@@ -163,6 +201,93 @@ export function SettingsSystemPanel() {
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Volume2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="监控告警语音播报"
desc="收到告警时用中文语音播报内容 (需浏览器支持, 默认关闭)"
checked={voiceEnabled}
disabled={!toastEnabled}
onChange={(v) => {
localStorage.setItem('voice_broadcast_enabled', v ? '1' : '0')
setVoiceEnabled(v)
if (v) { activateVoice(); previewVoice() } // 开启即激活 + 试听一句
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-center gap-1.5">
<Volume2 className="h-3.5 w-3.5 text-muted" />
<div>
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate">
{voices.length === 0
? '未检测到中文语音, 将用系统默认'
: '默认优先 Google 中国大陆 (音质最佳)'}
</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<select
value={voiceURI}
disabled={!toastEnabled || !voiceEnabled}
onChange={(e) => {
const v = e.target.value
if (v) {
// 手选某一语音包
localStorage.setItem('voice_broadcast_voice', v)
setVoiceConfigured(v)
setVoiceURI(v)
} else {
// 选"默认偏好": 清空手选, 走 Google 中国大陆偏好
localStorage.removeItem('voice_broadcast_voice')
setVoiceConfigured('')
setVoiceURI(getCurrentVoiceURI())
}
}}
className="w-32 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
<option value={getCurrentVoiceURI()}></option>
{voices
.filter(v => v.voiceURI !== getCurrentVoiceURI())
.map(v => <option key={v.voiceURI} value={v.voiceURI}>{v.name}</option>)
}
</select>
<button
onClick={() => previewVoice()}
disabled={!toastEnabled || !voiceEnabled}
className="px-2 h-8 rounded-btn border border-border bg-base text-xs text-secondary hover:text-foreground hover:border-accent/30 disabled:opacity-50 transition-colors cursor-pointer"
>
</button>
</div>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate">0.5 2.0 </div>
</div>
<div className="flex items-center gap-2 shrink-0">
<input
type="range" min={0.5} max={2} step={0.1} value={voiceRate}
disabled={!toastEnabled || !voiceEnabled}
onChange={(e) => {
const v = parseFloat(e.target.value)
localStorage.setItem('voice_broadcast_rate', String(v))
setVoiceRate(v)
}}
className="w-32 disabled:opacity-50"
/>
<span className="text-xs text-muted w-8 text-right">{voiceRate.toFixed(1)}</span>
</div>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />