board-finance.ts _loadAIInsights 改造: 1. cache_type 动态切换: area='all' → 'app2_finance' area != 'all' → 'app2a_finance_area' 2. seq 精确匹配(替代末两条启发式): - map 阶段保留 seq 字段 (Number(item.seq) || idx+1) - _extractSummary 优先 find(i => i.seq === 11/12) - 回退:找不到时用末两条启发式 3. UX bug 修复: 原代码 cache miss 时静默 return 导致切换区域后 UI 保留上个区域陈旧数据 修复:进入函数先 setData 清空 aiInsights / aiInsightSummary / aiInsightDetails / summaryLightType / summaryLightLabel 实测:微信开发者 MCP E2E 验证: - 全域面板 12 条 + 🔴 红灯 + seq 1-12 精确 - 切 vip 显示 app2a "客单价异动 321 元 符合 VIP 高客单定位" - 切 mahjong 显示 app2a "麻将房成交收入 46,339 元 + 🟡 黄灯" - 业态差异化识别准确 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
703 lines
29 KiB
TypeScript
703 lines
29 KiB
TypeScript
/* AI_CHANGELOG
|
||
| 日期 | Prompt | 变更 |
|
||
|------|--------|------|
|
||
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
|
||
| 2026-03-27 | board-finance-integration T3.1-T3.3 | _loadGiftRows→_loadData 绑定全部 6 板块;筛选联动调 _loadData;areaOptions 9 项 |
|
||
| 2026-03-27 | board-finance-integration T3.1 fix | _loadData 移除 formatMoney,所有金额/百分比字段传原始数字,由 WXS 负责格式化,修复双重格式化导致 NaN |
|
||
| 2026-07-22 | 财务看板多问题修复 | discountRate/balanceRate ×100 修复百分比计算(后端返回小数,WXS 期望百分比数字) |
|
||
| 2026-03-28 | 环比修复 | _loadData 绑定所有板块的 is_down/is_flat;mapExpenseItems/mapCoachTable 读取后端真实涨跌方向 |
|
||
*/
|
||
// 财务看板页 — 忠于 H5 原型结构
|
||
|
||
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
|
||
import { getRandomAiColor } from '../../utils/ai-color'
|
||
import { fetchBoardFinance, fetchAICache } from '../../services/api'
|
||
|
||
function isCurrentMonthFilter(selectedTime: string): boolean {
|
||
return selectedTime === 'month' && new Date().getDate() <= 5
|
||
}
|
||
|
||
// 2026-04-22 小程序轻量 Markdown 内联解析:支持 **加粗** / *倾斜* / _倾斜_ / ***加粗倾斜***
|
||
// - 不支持块级(标题、列表、代码块),AI 洞察文案为单段纯文本,足够覆盖
|
||
// - 输出片段数组,供 WXML 通过 <text> 循环渲染不同样式
|
||
interface MarkdownSeg {
|
||
text: string
|
||
bold?: boolean
|
||
italic?: boolean
|
||
}
|
||
|
||
function parseMarkdownInline(text: string): MarkdownSeg[] {
|
||
if (!text) return []
|
||
const segs: MarkdownSeg[] = []
|
||
// 先匹配 ***x***(加粗倾斜)、再匹配 **x**(加粗)、再匹配 *x* / _x_(倾斜)、__x__(加粗)
|
||
const re = /\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|_([^_\s][^_]*)_/g
|
||
let last = 0
|
||
let m: RegExpExecArray | null
|
||
while ((m = re.exec(text)) !== null) {
|
||
if (m.index > last) {
|
||
segs.push({ text: text.slice(last, m.index) })
|
||
}
|
||
if (m[1] !== undefined) segs.push({ text: m[1], bold: true, italic: true })
|
||
else if (m[2] !== undefined) segs.push({ text: m[2], bold: true })
|
||
else if (m[3] !== undefined) segs.push({ text: m[3], bold: true })
|
||
else if (m[4] !== undefined) segs.push({ text: m[4], italic: true })
|
||
else if (m[5] !== undefined) segs.push({ text: m[5], italic: true })
|
||
last = m.index + m[0].length
|
||
}
|
||
if (last < text.length) {
|
||
segs.push({ text: text.slice(last) })
|
||
}
|
||
return segs.length ? segs : [{ text }]
|
||
}
|
||
|
||
interface TocItem {
|
||
emoji: string
|
||
title: string
|
||
sectionId: string
|
||
}
|
||
|
||
const tipContents: Record<string, { title: string; content: string }> = {
|
||
occurrence: { title: '发生额/正价', content: '所有消费项目按标价计算的总金额,不扣除任何优惠。\n\n即"如果没有任何折扣,客户应付多少"。' },
|
||
discount: { title: '总优惠', content: '包含会员折扣、赠送卡抵扣、团购差价等所有优惠金额。\n\n优惠越高,实际收入越低。' },
|
||
confirmed: { title: '成交/确认收入', content: '发生额减去总优惠后的实际入账金额。\n\n成交收入 = 发生额 - 总优惠' },
|
||
cashIn: { title: '实收/现金流入', content: '实际到账的现金,包含消费直接支付和储值充值。\n\n往期为已结算金额,本期为截至当前的发生额。' },
|
||
cashOut: { title: '现金支出', content: '包含人工、房租、水电、进货等所有经营支出。' },
|
||
balance: { title: '现金结余', content: '现金流入减去现金支出。\n\n现金结余 = 现金流入 - 现金支出' },
|
||
rechargeActual: { title: '储值卡充值实收', content: '会员储值卡首充和续费的实际到账金额。\n\n不含赠送金额。' },
|
||
firstCharge: { title: '首充', content: '新会员首次充值的金额。' },
|
||
renewCharge: { title: '续费', content: '老会员续费充值的金额。' },
|
||
consume: { title: '消耗', content: '会员使用储值卡消费的金额。' },
|
||
cardBalance: { title: '储值卡总余额', content: '所有储值卡的剩余可用余额。' },
|
||
allCardBalance: { title: '全类别会员卡余额合计', content: '储值卡 + 赠送卡(酒水卡、台费卡、抵用券)的总余额。\n\n仅供经营参考,非财务属性。' },
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
pageState: 'normal' as 'loading' | 'empty' | 'error' | 'normal',
|
||
aiColorClass: '',
|
||
boardTabs: [] as Array<{ key: string; label: string; active: boolean }>,
|
||
|
||
selectedTime: 'month',
|
||
selectedTimeText: '本月',
|
||
timeOptions: [
|
||
{ value: 'month', text: '本月' },
|
||
{ value: 'lastMonth', text: '上月' },
|
||
{ value: 'week', text: '本周' },
|
||
{ value: 'lastWeek', text: '上周' },
|
||
{ value: 'quarter3', text: '前3个月 不含本月' },
|
||
{ value: 'quarter', text: '本季度' },
|
||
{ value: 'lastQuarter', text: '上季度' },
|
||
{ value: 'half6', text: '最近6个月不含本月' },
|
||
],
|
||
|
||
selectedArea: 'all',
|
||
selectedAreaText: '全部区域',
|
||
areaOptions: [
|
||
{ value: 'all', text: '全部区域' },
|
||
{ value: 'hall', text: '大厅' },
|
||
{ value: 'hallA', text: 'A区' },
|
||
{ value: 'hallB', text: 'B区' },
|
||
{ value: 'hallC', text: 'C区' },
|
||
{ value: 'vip', text: '台球包厢' },
|
||
{ value: 'snooker', text: '斯诺克' },
|
||
{ value: 'mahjong', text: '麻将房' },
|
||
{ value: 'ktv', text: '团建房' },
|
||
],
|
||
|
||
compareEnabled: false,
|
||
isCurrentMonth: true,
|
||
// 2026-04-22 改版:拆成 title/body 两段,title 在 wxml 渲染为 ai-insight-dim(灰色标题),body 为正文
|
||
aiInsights: [] as Array<{ title: string; body: string }>,
|
||
// 2026-04-22 seq11/12 置顶:从 aiInsights 派生,供"本期总结"卡片渲染
|
||
aiInsightSummary: {
|
||
evaluation: null as null | { title: string; body: string },
|
||
tracking: null as null | { title: string; body: string },
|
||
},
|
||
aiInsightDetails: [] as Array<{ title: string; body: string }>,
|
||
summaryLightType: '' as '' | 'green' | 'yellow' | 'red',
|
||
summaryLightLabel: '',
|
||
aiInsightsModalVisible: false, // 查看全部弹窗可见性
|
||
|
||
tocVisible: false,
|
||
tocItems: [
|
||
{ emoji: '📈', title: '经营一览', sectionId: 'section-overview' },
|
||
{ emoji: '💳', title: '预收资产', sectionId: 'section-recharge' },
|
||
{ emoji: '💰', title: '应计收入确认', sectionId: 'section-revenue' },
|
||
{ emoji: '🧾', title: '现金流入', sectionId: 'section-cashflow' },
|
||
{ emoji: '📤', title: '现金流出', sectionId: 'section-expense' },
|
||
{ emoji: '🎱', title: '助教分析', sectionId: 'section-coach' },
|
||
] as TocItem[],
|
||
currentSectionIndex: 0,
|
||
|
||
stickyHeaderVisible: false,
|
||
stickyHeaderEmoji: '',
|
||
stickyHeaderTitle: '',
|
||
stickyHeaderDesc: '',
|
||
|
||
tipVisible: false,
|
||
tipTitle: '',
|
||
tipContent: '',
|
||
|
||
/** 经营一览 */
|
||
overview: {
|
||
occurrence: 0 as any, occurrenceCompare: '', occurrenceDown: false, occurrenceFlat: false,
|
||
discount: 0 as any, discountCompare: '', discountDown: false, discountFlat: false,
|
||
discountRate: 0 as any, discountRateCompare: '', discountRateDown: false, discountRateFlat: false,
|
||
confirmedRevenue: 0 as any, confirmedCompare: '', confirmedDown: false, confirmedFlat: false,
|
||
cashIn: 0 as any, cashInCompare: '', cashInDown: false, cashInFlat: false,
|
||
cashOut: 0 as any, cashOutCompare: '', cashOutDown: false, cashOutFlat: false,
|
||
cashBalance: 0 as any, cashBalanceCompare: '', cashBalanceDown: false, cashBalanceFlat: false,
|
||
balanceRate: 0 as any, balanceRateCompare: '', balanceRateDown: false, balanceRateFlat: false,
|
||
},
|
||
|
||
/** 预收资产 */
|
||
recharge: {
|
||
actualIncome: 0 as any, actualCompare: '', actualDown: false,
|
||
firstCharge: 0 as any, firstChargeCompare: '', firstChargeDown: false,
|
||
renewCharge: 0 as any, renewChargeCompare: '', renewChargeDown: false,
|
||
consumed: 0 as any, consumedCompare: '', consumedDown: false,
|
||
cardBalance: 0 as any, cardBalanceCompare: '', cardBalanceDown: false,
|
||
allCardBalance: 0 as any, allCardBalanceCompare: '', allCardBalanceDown: false,
|
||
giftRows: [] as Array<{
|
||
label: string; total: any; totalCompare: string;
|
||
wine: any; wineCompare: string;
|
||
table: any; tableCompare: string;
|
||
coupon: any; couponCompare: string;
|
||
}>,
|
||
},
|
||
|
||
/** 应计收入确认 */
|
||
revenue: {
|
||
structureRows: [] as any[],
|
||
priceItems: [] as any[],
|
||
totalOccurrence: 0 as any,
|
||
totalOccurrenceCompare: '',
|
||
discountItems: [] as any[],
|
||
discountTotal: 0,
|
||
confirmedTotal: 0 as any,
|
||
confirmedTotalCompare: '',
|
||
channelItems: [] as any[],
|
||
},
|
||
|
||
/** 现金流入 */
|
||
cashflow: {
|
||
consumeItems: [] as any[],
|
||
rechargeItems: [] as any[],
|
||
total: 0 as any,
|
||
totalCompare: '',
|
||
},
|
||
|
||
/** 现金流出 */
|
||
expense: {
|
||
operationItems: [] as any[],
|
||
fixedItems: [] as any[],
|
||
coachItems: [] as any[],
|
||
platformItems: [] as any[],
|
||
total: 0 as any,
|
||
totalCompare: '',
|
||
totalDown: false,
|
||
totalFlat: false,
|
||
},
|
||
|
||
/** 助教分析 */
|
||
coachAnalysis: {
|
||
basic: {
|
||
totalPay: 0 as any, totalPayCompare: '', totalPayDown: false,
|
||
totalShare: 0 as any, totalShareCompare: '', totalShareDown: false,
|
||
avgHourly: 0 as any, avgHourlyCompare: '', avgHourlyFlat: false,
|
||
rows: [] as any[],
|
||
},
|
||
incentive: {
|
||
totalPay: 0 as any, totalPayCompare: '', totalPayDown: false,
|
||
totalShare: 0 as any, totalShareCompare: '', totalShareDown: false,
|
||
avgHourly: 0 as any, avgHourlyCompare: '', avgHourlyFlat: false,
|
||
rows: [] as any[],
|
||
},
|
||
},
|
||
},
|
||
|
||
onLoad() {
|
||
const aiColor = getRandomAiColor()
|
||
this.setData({ aiColorClass: aiColor.className })
|
||
},
|
||
|
||
onShow() {
|
||
checkPageAccess('pages/board-finance/board-finance').then((allowed) => {
|
||
if (!allowed) return
|
||
const TAB_LABELS: Record<string, string> = { finance: '财务', customer: '客户', coach: '助教' }
|
||
const visibleKeys = getVisibleBoardTabs()
|
||
this.setData({
|
||
boardTabs: visibleKeys.map(k => ({ key: k, label: TAB_LABELS[k] || k, active: k === 'finance' })),
|
||
})
|
||
const tabBar = this.getTabBar?.()
|
||
if (tabBar) tabBar.setData({ active: 'board' })
|
||
this._loadData()
|
||
})
|
||
},
|
||
|
||
onReady() {
|
||
this._cacheSectionPositions()
|
||
},
|
||
|
||
onPageScroll(e: { scrollTop: number }) {
|
||
const now = Date.now()
|
||
if (now - this._lastScrollTime < 100) return
|
||
this._lastScrollTime = now
|
||
|
||
const scrollTop = e.scrollTop
|
||
const isScrollingDown = scrollTop > this._lastScrollTop
|
||
this._lastScrollTop = scrollTop
|
||
|
||
if (this._sectionTops.length === 0) return
|
||
|
||
const offset = 100
|
||
let currentIdx = 0
|
||
for (let i = this._sectionTops.length - 1; i >= 0; i--) {
|
||
if (scrollTop + offset >= this._sectionTops[i]) {
|
||
currentIdx = i
|
||
break
|
||
}
|
||
}
|
||
|
||
if (scrollTop < 80) {
|
||
if (this.data.stickyHeaderVisible) {
|
||
this.setData({ stickyHeaderVisible: false })
|
||
}
|
||
return
|
||
}
|
||
|
||
const toc = this.data.tocItems[currentIdx]
|
||
|
||
if (isScrollingDown && !this.data.stickyHeaderVisible) {
|
||
this.setData({
|
||
stickyHeaderVisible: true,
|
||
stickyHeaderEmoji: toc?.emoji || '',
|
||
stickyHeaderTitle: toc?.title || '',
|
||
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
|
||
currentSectionIndex: currentIdx,
|
||
})
|
||
} else if (!isScrollingDown && this.data.stickyHeaderVisible) {
|
||
this.setData({ stickyHeaderVisible: false })
|
||
} else if (this.data.stickyHeaderVisible && currentIdx !== this.data.currentSectionIndex) {
|
||
this.setData({
|
||
stickyHeaderEmoji: toc?.emoji || '',
|
||
stickyHeaderTitle: toc?.title || '',
|
||
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
|
||
currentSectionIndex: currentIdx,
|
||
})
|
||
}
|
||
},
|
||
|
||
_sectionTops: [] as number[],
|
||
_lastScrollTop: 0,
|
||
_lastScrollTime: 0,
|
||
|
||
_sectionDescs: [
|
||
'快速了解收入与现金流的整体健康度',
|
||
'会员卡充值与余额 掌握资金沉淀',
|
||
'从发生额到入账收入的全流程',
|
||
'实际到账的资金来源明细',
|
||
'清晰呈现各类开销与结构',
|
||
'全部助教服务收入与分成的平均值',
|
||
] as string[],
|
||
|
||
_getSectionDesc(index: number): string {
|
||
return this._sectionDescs[index] || ''
|
||
},
|
||
|
||
_cacheSectionPositions() {
|
||
const sectionIds = this.data.tocItems.map(item => item.sectionId)
|
||
const query = wx.createSelectorQuery().in(this)
|
||
sectionIds.forEach(id => {
|
||
query.select(`#${id}`).boundingClientRect()
|
||
})
|
||
query.exec((results: Array<WechatMiniprogram.BoundingClientRectCallbackResult | null>) => {
|
||
if (!results) return
|
||
this._sectionTops = results.map(r => (r ? r.top : 0))
|
||
})
|
||
},
|
||
|
||
// CHANGE 2026-03-28 | 环比修复 | 绑定所有板块 is_down/is_flat,修复箭头方向硬编码问题
|
||
async _loadData() {
|
||
wx.showLoading({ title: '加载中' })
|
||
this.setData({ pageState: 'loading' })
|
||
try {
|
||
const data = await fetchBoardFinance({
|
||
time: this.data.selectedTime,
|
||
area: this.data.selectedArea,
|
||
compare: this.data.compareEnabled ? 1 : 0,
|
||
})
|
||
|
||
// 1. 经营一览
|
||
const ov = data.overview || {} as any
|
||
this.setData({
|
||
'overview.occurrence': ov.occurrence ?? 0,
|
||
'overview.discount': ov.discount ?? 0,
|
||
'overview.discountRate': (ov.discountRate ?? 0) * 100,
|
||
'overview.confirmedRevenue': ov.confirmedRevenue ?? 0,
|
||
'overview.cashIn': ov.cashIn ?? 0,
|
||
'overview.cashOut': ov.cashOut ?? 0,
|
||
'overview.cashBalance': ov.cashBalance ?? 0,
|
||
'overview.balanceRate': (ov.balanceRate ?? 0) * 100,
|
||
'overview.occurrenceCompare': ov.occurrenceCompare || '',
|
||
'overview.occurrenceDown': ov.occurrenceDown ?? false,
|
||
'overview.occurrenceFlat': ov.occurrenceFlat ?? false,
|
||
'overview.discountCompare': ov.discountCompare || '',
|
||
'overview.discountDown': ov.discountDown ?? false,
|
||
'overview.discountFlat': ov.discountFlat ?? false,
|
||
'overview.discountRateCompare': ov.discountRateCompare || '',
|
||
'overview.discountRateDown': ov.discountRateDown ?? false,
|
||
'overview.discountRateFlat': ov.discountRateFlat ?? false,
|
||
'overview.confirmedCompare': ov.confirmedRevenueCompare || '',
|
||
'overview.confirmedDown': ov.confirmedRevenueDown ?? false,
|
||
'overview.confirmedFlat': ov.confirmedRevenueFlat ?? false,
|
||
'overview.cashInCompare': ov.cashInCompare || '',
|
||
'overview.cashInDown': ov.cashInDown ?? false,
|
||
'overview.cashInFlat': ov.cashInFlat ?? false,
|
||
'overview.cashOutCompare': ov.cashOutCompare || '',
|
||
'overview.cashOutDown': ov.cashOutDown ?? false,
|
||
'overview.cashOutFlat': ov.cashOutFlat ?? false,
|
||
'overview.cashBalanceCompare': ov.cashBalanceCompare || '',
|
||
'overview.cashBalanceDown': ov.cashBalanceDown ?? false,
|
||
'overview.cashBalanceFlat': ov.cashBalanceFlat ?? false,
|
||
'overview.balanceRateCompare': ov.balanceRateCompare || '',
|
||
'overview.balanceRateDown': ov.balanceRateDown ?? false,
|
||
'overview.balanceRateFlat': ov.balanceRateFlat ?? false,
|
||
})
|
||
|
||
// 2. 预收资产(area=all 时后端才返回)
|
||
if (data.recharge) {
|
||
const rc = data.recharge as any
|
||
const giftRows = (rc.giftRows || []).map((row: any) => ({
|
||
label: row.label || '',
|
||
total: row.total?.value ?? 0,
|
||
totalCompare: row.total?.compare || '',
|
||
wine: row.liquor?.value ?? 0,
|
||
wineCompare: row.liquor?.compare || '',
|
||
table: row.tableFee?.value ?? 0,
|
||
tableCompare: row.tableFee?.compare || '',
|
||
coupon: row.voucher?.value ?? 0,
|
||
couponCompare: row.voucher?.compare || '',
|
||
}))
|
||
this.setData({
|
||
'recharge.actualIncome': rc.actualIncome ?? 0,
|
||
'recharge.firstCharge': rc.firstCharge ?? 0,
|
||
'recharge.renewCharge': rc.renewCharge ?? 0,
|
||
'recharge.consumed': rc.consumed ?? 0,
|
||
'recharge.cardBalance': rc.cardBalance ?? 0,
|
||
'recharge.allCardBalance': rc.allCardBalance ?? 0,
|
||
'recharge.giftRows': giftRows,
|
||
'recharge.actualCompare': rc.actualIncomeCompare || '',
|
||
'recharge.actualDown': rc.actualIncomeDown ?? false,
|
||
'recharge.firstChargeCompare': rc.firstChargeCompare || '',
|
||
'recharge.firstChargeDown': rc.firstChargeDown ?? false,
|
||
'recharge.renewChargeCompare': rc.renewChargeCompare || '',
|
||
'recharge.renewChargeDown': rc.renewChargeDown ?? false,
|
||
'recharge.consumedCompare': rc.consumedCompare || '',
|
||
'recharge.consumedDown': rc.consumedDown ?? false,
|
||
'recharge.cardBalanceCompare': rc.cardBalanceCompare || '',
|
||
'recharge.cardBalanceDown': rc.cardBalanceDown ?? false,
|
||
'recharge.allCardBalanceCompare': rc.allCardBalanceCompare || '',
|
||
'recharge.allCardBalanceDown': rc.allCardBalanceDown ?? false,
|
||
})
|
||
}
|
||
|
||
// 3. 应计收入确认
|
||
const rv = data.revenue || {} as any
|
||
const structureRows = (rv.structureRows || []).map((r: any) => ({
|
||
id: r.id || '', name: r.name || '', desc: r.desc || '',
|
||
isSub: r.isSub || false,
|
||
amount: r.amount ?? 0, discount: r.discount ?? 0,
|
||
booked: r.booked ?? 0, bookedCompare: r.bookedCompare || '',
|
||
}))
|
||
const priceItems = (rv.priceItems || []).map((r: any) => ({ name: r.label || '', value: r.amount ?? 0, compare: r.compare || '' }))
|
||
const discountItems = (rv.discountItems || []).map((r: any) => ({ name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '' }))
|
||
const channelItems = (rv.channelItems || []).map((r: any) => ({ name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '' }))
|
||
this.setData({
|
||
'revenue.structureRows': structureRows,
|
||
'revenue.priceItems': priceItems,
|
||
'revenue.totalOccurrence': rv.totalOccurrence ?? 0,
|
||
// CHANGE 2026-03-28 | 环比字段映射
|
||
'revenue.totalOccurrenceCompare': rv.totalOccurrenceCompare || '',
|
||
'revenue.discountItems': discountItems,
|
||
'revenue.discountTotal': rv.discountTotal ?? 0,
|
||
'revenue.confirmedTotal': rv.confirmedTotal ?? 0,
|
||
'revenue.confirmedTotalCompare': rv.confirmedTotalCompare || '',
|
||
'revenue.channelItems': channelItems,
|
||
})
|
||
|
||
// 4. 现金流入
|
||
const cf = data.cashflow || {} as any
|
||
const consumeItems = (cf.consumeItems || []).map((r: any) => ({
|
||
name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '', isDown: r.down ?? false,
|
||
}))
|
||
const rechargeItems = (cf.rechargeItems || []).map((r: any) => ({
|
||
name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '',
|
||
}))
|
||
this.setData({
|
||
'cashflow.consumeItems': consumeItems,
|
||
'cashflow.rechargeItems': rechargeItems,
|
||
'cashflow.total': cf.total ?? 0,
|
||
// CHANGE 2026-03-28 | 环比字段映射
|
||
'cashflow.totalCompare': cf.totalCompare || '',
|
||
})
|
||
|
||
// 5. 现金流出
|
||
const ex = data.expense || {} as any
|
||
const mapExpenseItems = (items: any[]) => (items || []).map((r: any) => ({
|
||
name: r.label || '', value: r.amount ?? 0,
|
||
compare: r.compare || '',
|
||
isDown: r.down ?? false,
|
||
isFlat: r.flat ?? false,
|
||
}))
|
||
this.setData({
|
||
'expense.operationItems': mapExpenseItems(ex.operationItems),
|
||
'expense.fixedItems': mapExpenseItems(ex.fixedItems),
|
||
'expense.coachItems': mapExpenseItems(ex.coachItems),
|
||
'expense.platformItems': mapExpenseItems(ex.platformItems),
|
||
'expense.total': ex.total ?? 0,
|
||
'expense.totalCompare': ex.totalCompare || '',
|
||
'expense.totalDown': ex.totalDown ?? false,
|
||
'expense.totalFlat': ex.totalFlat ?? false,
|
||
})
|
||
|
||
// 6. 助教分析
|
||
const ca = data.coachAnalysis || {} as any
|
||
const mapCoachTable = (table: any) => {
|
||
if (!table) return { totalPay: 0, totalPayCompare: '', totalPayDown: false, totalShare: 0, totalShareCompare: '', totalShareDown: false, avgHourly: 0, avgHourlyCompare: '', avgHourlyFlat: false, rows: [] }
|
||
return {
|
||
totalPay: table.totalPay ?? 0,
|
||
totalPayCompare: table.totalPayCompare || '',
|
||
totalPayDown: table.totalPayDown ?? false,
|
||
totalShare: table.totalShare ?? 0,
|
||
totalShareCompare: table.totalShareCompare || '',
|
||
totalShareDown: table.totalShareDown ?? false,
|
||
avgHourly: table.avgHourly ?? 0,
|
||
avgHourlyCompare: table.avgHourlyCompare || '',
|
||
avgHourlyFlat: table.avgHourlyFlat ?? false,
|
||
rows: (table.rows || []).map((r: any) => ({
|
||
level: r.level || '',
|
||
pay: r.pay ?? 0,
|
||
payCompare: r.payCompare || '',
|
||
payDown: r.payDown ?? false,
|
||
share: r.share ?? 0,
|
||
shareCompare: r.shareCompare || '',
|
||
shareDown: r.shareDown ?? false,
|
||
hourly: r.hourly ?? 0,
|
||
hourlyCompare: r.hourlyCompare || '',
|
||
hourlyFlat: r.hourlyFlat ?? false,
|
||
})),
|
||
}
|
||
}
|
||
this.setData({
|
||
coachAnalysis: {
|
||
basic: mapCoachTable(ca.basic),
|
||
incentive: mapCoachTable(ca.incentive),
|
||
},
|
||
})
|
||
|
||
// 后端不返回 aiInsights,仅在有值时覆盖(避免重载时清空旧缓存洞察)
|
||
const dataToSet: Record<string, any> = { pageState: 'normal' }
|
||
if (Array.isArray(data.aiInsights) && data.aiInsights.length > 0) {
|
||
dataToSet.aiInsights = data.aiInsights
|
||
}
|
||
this.setData(dataToSet)
|
||
|
||
// Phase 2.5:独立加载 AI 财务洞察缓存(不阻塞主流程)
|
||
// 2026-04-21:缓存按 time__area 粒度预热(cron 每日 10:00 × 72 组合),按当前筛选读缓存
|
||
this._loadAIInsights(this.data.selectedTime, this.data.selectedArea).catch(() => {})
|
||
|
||
} catch (err) {
|
||
console.error('[board-finance] 数据加载失败', err)
|
||
this.setData({ pageState: 'error' })
|
||
wx.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||
} finally {
|
||
wx.hideLoading()
|
||
}
|
||
},
|
||
|
||
async _loadAIInsights(selectedTime: string, selectedArea: string) {
|
||
// 2026-04-21:缓存粒度 time__area;后端 dispatcher._app2_target_id 同款拼装规则
|
||
// 2026-04-23:按 area 切 cache_type(全域 app2_finance / 区域 app2a_finance_area)
|
||
const TIME_MAP: Record<string, string> = {
|
||
month: 'this_month', lastMonth: 'last_month',
|
||
week: 'this_week', lastWeek: 'last_week',
|
||
quarter: 'this_quarter', lastQuarter: 'last_quarter',
|
||
quarter3: 'last_3_months', half6: 'last_6_months',
|
||
}
|
||
const timeKey = TIME_MAP[selectedTime]
|
||
if (!timeKey) return
|
||
const areaKey = selectedArea || 'all'
|
||
const targetId = `${timeKey}__${areaKey}`
|
||
const cacheType = areaKey === 'all' ? 'app2_finance' : 'app2a_finance_area'
|
||
// 切换筛选前先清空当前展示的洞察,避免 cache miss 时仍显示上个区域的陈旧数据
|
||
this.setData({
|
||
aiInsights: [],
|
||
aiInsightSummary: { evaluation: null, tracking: null },
|
||
aiInsightDetails: [],
|
||
summaryLightType: '',
|
||
summaryLightLabel: '',
|
||
})
|
||
const cache = await fetchAICache(cacheType, targetId)
|
||
if (!cache?.result_json) return
|
||
const rj = cache.result_json as any
|
||
// 2026-04-22:忠于 demo 两段式渲染(dim 标题 + 白色正文),拆分 title/body 存到 data
|
||
// 百炼返回字段 content(兜底 body);seq 字段用于 _extractSummary 精确定位 seq11/12
|
||
const insights = Array.isArray(rj.insights)
|
||
? rj.insights.map((item: any, idx: number) => {
|
||
const title = (item.title || '').trim()
|
||
const body = (item.content || item.body || '').trim()
|
||
const seq = Number(item.seq) || (idx + 1)
|
||
return {
|
||
seq,
|
||
title,
|
||
body,
|
||
titleSegs: parseMarkdownInline(title),
|
||
bodySegs: parseMarkdownInline(body),
|
||
}
|
||
}).filter((i: { title: string; body: string }) => i.title.length > 0 || i.body.length > 0)
|
||
: []
|
||
if (insights.length === 0) return
|
||
// 2026-04-22 seq11/12 置顶:识别"本期总结"(健康度评级 + 跟踪指标)
|
||
// - 优先按 seq 字段精确匹配(seq=11 健康度 · seq=12 跟踪指标)
|
||
// - 失败时回退到"末两条启发式",兼容旧缓存或输出顺序错位
|
||
const summary = this._extractSummary(insights)
|
||
this.setData({
|
||
aiInsights: insights,
|
||
aiInsightSummary: summary.summary,
|
||
aiInsightDetails: summary.details,
|
||
summaryLightType: summary.lightType,
|
||
summaryLightLabel: summary.lightLabel,
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 识别 seq11(健康度) + seq12(跟踪指标),并解析三色灯类型。
|
||
* 规则:
|
||
* 1. 优先按 item.seq === 11 / 12 精确匹配
|
||
* 2. 找不到时回退"末两条启发式"(数组长度 ≥ 4)
|
||
* 3. 数组过短直接降级为全量明细无总结
|
||
*/
|
||
_extractSummary(insights: Array<{ seq?: number; title: string; body: string }>) {
|
||
const empty = {
|
||
summary: { evaluation: null, tracking: null },
|
||
details: insights,
|
||
lightType: '' as '' | 'green' | 'yellow' | 'red',
|
||
lightLabel: '',
|
||
}
|
||
if (insights.length < 4) return empty
|
||
|
||
// 优先按 seq 精确匹配
|
||
let evaluation = insights.find(i => i.seq === 11) || null
|
||
let tracking = insights.find(i => i.seq === 12) || null
|
||
let details: typeof insights
|
||
|
||
if (evaluation && tracking) {
|
||
// seq 精确匹配成功 → 过滤掉 seq 11/12
|
||
details = insights.filter(i => i.seq !== 11 && i.seq !== 12)
|
||
} else {
|
||
// 回退:末两条启发式
|
||
evaluation = insights[insights.length - 2]
|
||
tracking = insights[insights.length - 1]
|
||
details = insights.slice(0, insights.length - 2)
|
||
}
|
||
// 三色灯识别:优先匹配 emoji,其次匹配文案
|
||
const text = `${evaluation.title} ${evaluation.body}`
|
||
let lightType: '' | 'green' | 'yellow' | 'red' = ''
|
||
let lightLabel = ''
|
||
if (/🔴|红灯|警告/.test(text)) {
|
||
lightType = 'red'
|
||
lightLabel = '🔴 红灯警告'
|
||
} else if (/🟡|黄灯|观察/.test(text)) {
|
||
lightType = 'yellow'
|
||
lightLabel = '🟡 黄灯观察'
|
||
} else if (/🟢|绿灯|健康/.test(text)) {
|
||
lightType = 'green'
|
||
lightLabel = '🟢 绿灯健康'
|
||
}
|
||
return {
|
||
summary: { evaluation, tracking },
|
||
details,
|
||
lightType,
|
||
lightLabel,
|
||
}
|
||
},
|
||
|
||
openAllInsights() {
|
||
this.setData({ aiInsightsModalVisible: true })
|
||
},
|
||
|
||
closeAllInsights() {
|
||
this.setData({ aiInsightsModalVisible: false })
|
||
},
|
||
|
||
/** 弹窗遮罩点击穿透阻断(避免滚动到 page 底) */
|
||
_noop() {},
|
||
|
||
onPullDownRefresh() {
|
||
this._loadData()
|
||
setTimeout(() => wx.stopPullDownRefresh(), 500)
|
||
},
|
||
|
||
// CHANGE 2026-03-29 | P1:redirectTo 替代 navigateTo
|
||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||
const tab = e.currentTarget.dataset.tab as string
|
||
if (tab === 'finance') return
|
||
const routes: Record<string, { url: string; isTab: boolean }> = {
|
||
finance: { url: '/pages/board-finance/board-finance', isTab: true },
|
||
customer: { url: '/pages/board-customer/board-customer', isTab: false },
|
||
coach: { url: '/pages/board-coach/board-coach', isTab: false },
|
||
}
|
||
const route = routes[tab]
|
||
if (!route) return
|
||
if (route.isTab) { wx.switchTab({ url: route.url }) } else { wx.redirectTo({ url: route.url }) }
|
||
},
|
||
|
||
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||
const value = e.detail.value
|
||
const option = this.data.timeOptions.find(o => o.value === value)
|
||
this.setData({ selectedTime: value, selectedTimeText: option?.text || '本月', isCurrentMonth: isCurrentMonthFilter(value) })
|
||
this._loadData()
|
||
},
|
||
|
||
onAreaChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||
const value = e.detail.value
|
||
const option = this.data.areaOptions.find(o => o.value === value)
|
||
this.setData({ selectedArea: value, selectedAreaText: option?.text || '全部区域' })
|
||
this._loadData()
|
||
setTimeout(() => this._cacheSectionPositions(), 300)
|
||
},
|
||
|
||
toggleCompare() {
|
||
this.setData({ compareEnabled: !this.data.compareEnabled })
|
||
this._loadData()
|
||
},
|
||
|
||
toggleToc() { this.setData({ tocVisible: !this.data.tocVisible }) },
|
||
closeToc() { this.setData({ tocVisible: false }) },
|
||
|
||
onTocItemTap(e: WechatMiniprogram.TouchEvent) {
|
||
const index = e.currentTarget.dataset.index as number
|
||
const sectionId = this.data.tocItems[index]?.sectionId
|
||
if (sectionId) {
|
||
this.setData({ tocVisible: false, currentSectionIndex: index })
|
||
wx.createSelectorQuery().in(this).select(`#${sectionId}`).boundingClientRect((rect) => {
|
||
if (rect) wx.pageScrollTo({ scrollTop: rect.top + (this._lastScrollTop || 0) - 140, duration: 300 })
|
||
}).exec()
|
||
}
|
||
},
|
||
|
||
onHelpTap(e: WechatMiniprogram.TouchEvent) {
|
||
const key = e.currentTarget.dataset.key as string
|
||
const tip = tipContents[key]
|
||
if (tip) this.setData({ tipVisible: true, tipTitle: tip.title, tipContent: tip.content })
|
||
},
|
||
|
||
closeTip() { this.setData({ tipVisible: false }) },
|
||
|
||
onRetry() {
|
||
this.setData({ pageState: 'normal' })
|
||
this._loadData()
|
||
},
|
||
})
|