feat: 新增本月经营态势弹窗+千问TTS语音播报
- 新增 BusinessReportDialog 组件,展示整体经营/成本费用/风险态势/利润机会四部分文字报告 - BossPage 标题栏新增「本月经营态势」按钮 - 后端新增 /api/tts 路由,调用千问 CosyVoice TTS API 合成语音 - 前端按句分段合成、逐句顺序播放,边播边合成 - 报告含待确认门店数量
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user