feat: 新增本月经营态势弹窗+千问TTS语音播报

- 新增 BusinessReportDialog 组件,展示整体经营/成本费用/风险态势/利润机会四部分文字报告
- BossPage 标题栏新增「本月经营态势」按钮
- 后端新增 /api/tts 路由,调用千问 CosyVoice TTS API 合成语音
- 前端按句分段合成、逐句顺序播放,边播边合成
- 报告含待确认门店数量
This commit is contained in:
freedakgmail
2026-08-03 22:55:50 +08:00
parent c15ca07b7c
commit 6bc2f43da7
5 changed files with 329 additions and 2 deletions
@@ -0,0 +1,233 @@
import { useState, useRef } from 'react'
import { Volume2, Square, X, Loader2 } from 'lucide-react'
import api from '@/lib/api'
import { formatCurrency, formatPercent } from '@/lib/utils'
interface BusinessReportDialogProps {
open: boolean
onClose: () => void
data: {
month: string
overview: any
expense: any
waterfall: any
riskRows: any[]
priorityRows: any[]
costOverview: any
profitOpp: any[]
totalOpportunity: number
trendReceived: number
}
}
export function BusinessReportDialog({ open, onClose, data }: BusinessReportDialogProps) {
const [ttsLoading, setTtsLoading] = useState(false)
const [ttsError, setTtsError] = useState<string | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const audioQueueRef = useRef<HTMLAudioElement[]>([])
const stopFlagRef = useRef(false)
if (!open) return null
const ex = data.expense
const wf = data.waterfall
const riskRows = data.riskRows || []
const priorityRows = data.priorityRows || []
const costOv = data.costOverview || {}
const profitOpp = data.profitOpp || []
const totalOpp = data.totalOpportunity || 0
const riskSummary = riskRows.reduce((acc: any, r: any) => {
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
return acc
}, {})
const p0Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0'))
const p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P1'))
const profitMargin = ex?.actual_net_margin_pct ? Number(ex.actual_net_margin_pct) : 0
const foodCostRate = wf ? (Number(wf.food_cost) / Number(wf.received) * 100) : 0
const expenseRate = wf ? (Number(wf.total_expense) / Number(wf.received) * 100) : 0
const contributionRate = wf ? (Number(wf.store_contribution) / Number(wf.received) * 100) : 0
const otherStores = (ex?.total_stores || 0) - (ex?.profitable_stores || 0) - (ex?.loss_stores || 0)
const reportText = `本月经营态势报告,${data.month}月。
一、整体经营。营业收入${formatCurrency(ex?.total_consumption)},优惠${formatCurrency(ex?.total_discount)},实收总额${formatCurrency(ex?.total_received)},环比上周${data.trendReceived > 0 ? '增长' : '下降'}${Math.abs(data.trendReceived)}%。门店共${ex?.total_stores}家,其中盈利${ex?.profitable_stores}家,亏损${ex?.loss_stores}${otherStores > 0 ? `,待确认${otherStores}` : ''}。客单价${formatCurrency(ex?.avg_bill_value)},账单${ex?.total_bills}笔。门店贡献利润${formatCurrency(ex?.actual_net_profit)},贡献率${formatPercent(profitMargin)}
二、成本费用。食材成本率${formatPercent(foodCostRate)},建议低于32%。费用率${formatPercent(expenseRate)},建议低于40%。贡献率${formatPercent(contributionRate)},建议高于10%。食材成本超耗严重${costOv.red_count || 0}家,明显超耗${costOv.orange_count || 0}家,基本正常${costOv.green_count || 0}家,总差异金额${formatCurrency(costOv.total_variance)}
三、风险态势。红色门店${riskSummary['红色'] || 0}家,黄色门店${riskSummary['黄色'] || 0}家,绿色门店${riskSummary['绿色'] || 0}家。重点整改门店P0级${p0Stores.length}家,P1级${p1Stores.length}家。
四、利润机会。理论月度机会合计${formatCurrency(totalOpp)},涉及${profitOpp.length}个改善方向。30天承诺值大于等于150万元,目标将门店贡献率从${formatPercent(profitMargin)}提升至约10%。`
const handleTTS = async () => {
if (isPlaying) {
stopFlagRef.current = true
audioQueueRef.current.forEach(a => { a.pause(); a.src = '' })
audioQueueRef.current = []
setIsPlaying(false)
return
}
setTtsLoading(true)
setTtsError(null)
stopFlagRef.current = false
const sentences = reportText.split(/(?<=[。!?\n])/).filter(s => s.trim().length > 0)
try {
const audioElements: HTMLAudioElement[] = []
audioQueueRef.current = audioElements
let nextPlayIndex = 0
let synthDone = false
let waitingForNext = false
setIsPlaying(true)
const tryPlayNext = () => {
if (stopFlagRef.current) return
if (nextPlayIndex < audioElements.length) {
const audio = audioElements[nextPlayIndex]
nextPlayIndex++
audio.play().catch(() => {})
} else if (synthDone) {
setIsPlaying(false)
} else {
waitingForNext = true
}
}
for (let i = 0; i < sentences.length; i++) {
if (stopFlagRef.current) break
const sentence = sentences[i].trim()
if (!sentence) continue
try {
const result: any = await api.post('/tts', { text: sentence })
if (stopFlagRef.current) break
if (result?.data?.url) {
const audio = new Audio(result.data.url)
audioElements.push(audio)
audio.onended = () => {
if (!stopFlagRef.current) tryPlayNext()
}
audio.onerror = () => {
if (!stopFlagRef.current) tryPlayNext()
}
if (i === 0) {
setTtsLoading(false)
tryPlayNext()
} else if (waitingForNext) {
waitingForNext = false
tryPlayNext()
}
}
} catch {
// skip failed sentence, continue to next
}
}
synthDone = true
if (waitingForNext) {
waitingForNext = false
tryPlayNext()
}
if (audioElements.length === 0) {
setTtsError('语音合成失败')
setIsPlaying(false)
setTtsLoading(false)
} else if (nextPlayIndex >= audioElements.length) {
setIsPlaying(false)
}
} catch (err: any) {
setTtsError(err?.message || '语音合成失败')
setIsPlaying(false)
} finally {
setTtsLoading(false)
}
}
const handleClose = () => {
stopFlagRef.current = true
audioQueueRef.current.forEach(a => { a.pause(); a.src = '' })
audioQueueRef.current = []
setIsPlaying(false)
setTtsError(null)
setTtsLoading(false)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={handleClose}>
<div
className="max-h-[85vh] w-[640px] max-w-[95vw] overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-base font-bold"></h2>
<span className="text-xs text-muted-foreground">{data.month}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleTTS}
disabled={ttsLoading}
className="flex items-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-600 transition hover:bg-blue-100 disabled:opacity-50"
>
{ttsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPlaying ? (
<Square className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
{ttsLoading ? '生成中...' : isPlaying ? '停止' : '语音播报'}
</button>
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-muted">
<X className="h-4 w-4" />
</button>
</div>
</div>
{ttsError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50/50 px-3 py-2 text-xs text-red-600">
{ttsError}
</div>
)}
{/* 报告文字内容 */}
<div className="space-y-3 text-sm leading-relaxed text-foreground">
<section>
<h3 className="mb-1 text-sm font-bold text-blue-700"></h3>
<p className="text-xs leading-relaxed">
<strong>{formatCurrency(ex?.total_consumption)}</strong> <strong>{formatCurrency(ex?.total_discount)}</strong> <strong>{formatCurrency(ex?.total_received)}</strong> <strong>{data.trendReceived > 0 ? '↑' : '↓'} {Math.abs(data.trendReceived)}%</strong> <strong>{ex?.total_stores}</strong> <strong className="text-green-600">{ex?.profitable_stores}</strong> <strong className="text-red-600">{ex?.loss_stores}</strong> {otherStores > 0 ? <> <strong className="text-muted-foreground">{otherStores}</strong> </> : null} <strong>{formatCurrency(ex?.avg_bill_value)}</strong> <strong>{ex?.total_bills}</strong> <strong>{formatCurrency(ex?.actual_net_profit)}</strong> <strong className={profitMargin < 10 ? 'text-red-600' : 'text-green-600'}>{formatPercent(profitMargin)}</strong>
</p>
</section>
<section>
<h3 className="mb-1 text-sm font-bold text-orange-700"></h3>
<p className="text-xs leading-relaxed">
<strong className={foodCostRate > 32 ? 'text-red-600' : 'text-green-600'}>{formatPercent(foodCostRate)}</strong> 32% <strong className={expenseRate > 40 ? 'text-red-600' : 'text-green-600'}>{formatPercent(expenseRate)}</strong> 40% <strong className={contributionRate < 10 ? 'text-red-600' : 'text-green-600'}>{formatPercent(contributionRate)}</strong> 10% <strong className="text-red-600">{costOv.red_count || 0}</strong> <strong className="text-orange-600">{costOv.orange_count || 0}</strong> <strong className="text-green-600">{costOv.green_count || 0}</strong> <strong className="text-red-600">{formatCurrency(costOv.total_variance)}</strong>
</p>
</section>
<section>
<h3 className="mb-1 text-sm font-bold text-red-700"></h3>
<p className="text-xs leading-relaxed">
<strong className="text-red-600">{riskSummary['红色'] || 0}</strong> <strong className="text-yellow-600">{riskSummary['黄色'] || 0}</strong> 绿 <strong className="text-green-600">{riskSummary['绿色'] || 0}</strong> P0 <strong className="text-red-600">{p0Stores.length}</strong> P1 <strong className="text-yellow-600">{p1Stores.length}</strong>
</p>
</section>
<section>
<h3 className="mb-1 text-sm font-bold text-purple-700"></h3>
<p className="text-xs leading-relaxed">
<strong className="text-purple-600">{formatCurrency(totalOpp)}</strong> <strong>{profitOpp.length}</strong> 30 <strong className="text-purple-600"> 150 </strong> <strong>{formatPercent(profitMargin)}</strong> <strong>10%</strong>
</p>
</section>
</div>
</div>
</div>
)
}
+31 -2
View File
@@ -7,9 +7,10 @@ import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown, Volume2 } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { KPISection } from '@/components/KPISection'
import { BusinessReportDialog } from '@/components/BusinessReportDialog'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -67,6 +68,7 @@ function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: an
export function BossPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-04')
const [showReport, setShowReport] = useState(false)
const { data: overview, isLoading: odLoading } = useQuery({
queryKey: ['overview', month],
@@ -219,7 +221,16 @@ export function BossPage() {
<p className="mt-0.5 text-xs text-muted-foreground"> · {month}</p>
</div>
</div>
<MonthPicker month={month} onChange={setMonth} />
<div className="flex items-center gap-2">
<button
onClick={() => setShowReport(true)}
className="flex items-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-600 transition hover:bg-blue-100"
>
<Volume2 className="h-4 w-4" />
</button>
<MonthPicker month={month} onChange={setMonth} />
</div>
</div>
{/* ① 核心经营指标 */}
@@ -516,6 +527,24 @@ export function BossPage() {
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 本月经营态势弹窗 */}
<BusinessReportDialog
open={showReport}
onClose={() => setShowReport(false)}
data={{
month,
overview: od,
expense: ex,
waterfall: wf,
riskRows,
priorityRows,
costOverview: costOv,
profitOpp,
totalOpportunity: totalOpportunity,
trendReceived,
}}
/>
</div>
)
}
+2
View File
@@ -68,6 +68,8 @@ ADMIN_DB_PORT=5432
ADMIN_DB_NAME=sbrain_admin
ADMIN_DB_USER=sbrain_admin
ADMIN_DB_PASSWORD=sbrain2026
TTS_API_KEY=sk-ws-H.EHYXPRE.Gv4a.MEQCIDOofoXP-VBQILl0c_kspJZhSUj8fyYIZupigveHnpRrAiAsjWXuJ3dpyTBDCUF1ZLdcvLz8GJoan0ObIkCwfE06kQ
TTS_API_HOST=llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com
ENVEOF"
echo "✓ 配置写入完成"
echo ""
+2
View File
@@ -13,6 +13,7 @@ import smartSchedulingRoutes from './routes/smart-scheduling.js'
import situationalAwarenessRoutes from './routes/situational-awareness.js'
import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
import adminRoutes from './routes/admin.js'
import ttsRoutes from './routes/tts.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -59,6 +60,7 @@ app.use('/api/store-expense', storeExpenseRoutes)
app.use('/api/smart-scheduling', smartSchedulingRoutes)
app.use('/api/situational-awareness', situationalAwarenessRoutes)
app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
app.use('/api/tts', ttsRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+61
View File
@@ -0,0 +1,61 @@
import { Router } from 'express'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
const TTS_API_KEY = process.env.TTS_API_KEY || 'sk-ws-H.EHYXPRE.Gv4a.MEQCIDOofoXP-VBQILl0c_kspJZhSUj8fyYIZupigveHnpRrAiAsjWXuJ3dpyTBDCUF1ZLdcvLz8GJoan0ObIkCwfE06kQ'
const TTS_API_HOST = process.env.TTS_API_HOST || 'llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com'
const TTS_URL = `https://${TTS_API_HOST}/api/v1/services/audio/tts/SpeechSynthesizer`
router.post('/', async (req: AuthRequest, res) => {
try {
const { text } = req.body
if (!text || typeof text !== 'string') {
return sendError(res, 'text is required')
}
if (text.length > 3000) {
return sendError(res, 'text too long (max 3000 characters)')
}
const response = await fetch(TTS_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TTS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'cosyvoice-v3-flash',
input: {
text,
voice: 'longanyang',
format: 'mp3',
sample_rate: 22050,
volume: 50,
rate: 1.0,
},
}),
})
if (!response.ok) {
const errText = await response.text()
console.error('TTS API error:', response.status, errText)
return sendError(res, `TTS API error: ${response.status}`, 502)
}
const result: any = await response.json()
if (result.output?.audio?.url) {
sendSuccess(res, { url: result.output.audio.url })
} else {
console.error('TTS API unexpected response:', JSON.stringify(result))
sendError(res, 'TTS API returned no audio URL', 502)
}
} catch (err: any) {
console.error('TTS route error:', err.message)
sendError(res, err.message, 500)
}
})
export default router