mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
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:
@@ -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
|
||||
|
||||
**🗄️ 数据与扩展**
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.84",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) // 副作用分发: 提示音 + 语音 (整批各一次)
|
||||
}
|
||||
|
||||
/** 手动关闭 */
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user