diff --git a/client/src/components/BusinessReportDialog.tsx b/client/src/components/BusinessReportDialog.tsx new file mode 100644 index 0000000..9773fa1 --- /dev/null +++ b/client/src/components/BusinessReportDialog.tsx @@ -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(null) + const [isPlaying, setIsPlaying] = useState(false) + const audioQueueRef = useRef([]) + 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 ( +
+
e.stopPropagation()} + > + {/* 标题栏 */} +
+
+

本月经营态势

+ {data.month} +
+
+ + +
+
+ + {ttsError && ( +
+ {ttsError} +
+ )} + + {/* 报告文字内容 */} +
+
+

一、整体经营

+

+ 营业收入 {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} 家 : null}。客单价 {formatCurrency(ex?.avg_bill_value)},账单 {ex?.total_bills} 笔。门店贡献利润 {formatCurrency(ex?.actual_net_profit)},贡献率 {formatPercent(profitMargin)}。 +

+
+ +
+

二、成本费用

+

+ 食材成本率 32 ? 'text-red-600' : 'text-green-600'}>{formatPercent(foodCostRate)}(建议 ≤ 32%),费用率 40 ? 'text-red-600' : 'text-green-600'}>{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%。 +

+
+
+
+
+ ) +} diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx index 7eb1303..d0c4586 100644 --- a/client/src/pages/BossPage.tsx +++ b/client/src/pages/BossPage.tsx @@ -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 = { '红色': '#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() {

经营全景 · {month}

- +
+ + +
{/* ① 核心经营指标 */} @@ -516,6 +527,24 @@ export function BossPage() { + + {/* 本月经营态势弹窗 */} + setShowReport(false)} + data={{ + month, + overview: od, + expense: ex, + waterfall: wf, + riskRows, + priorityRows, + costOverview: costOv, + profitOpp, + totalOpportunity: totalOpportunity, + trendReceived, + }} + /> ) } diff --git a/deploy.sh b/deploy.sh index 71971b5..739bc88 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 "" diff --git a/server/src/index.ts b/server/src/index.ts index 5b72eb2..bfa6c31 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) diff --git a/server/src/routes/tts.ts b/server/src/routes/tts.ts new file mode 100644 index 0000000..d397c28 --- /dev/null +++ b/server/src/routes/tts.ts @@ -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