Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts
Neo 66be873e70 feat(miniprogram): 财务看板按 area 切 cache_type + seq 精确匹配 + UX 修复
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>
2026-04-22 21:55:58 +08:00

703 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | board-finance-integration T3.1-T3.3 | _loadGiftRows→_loadData 绑定全部 6 板块;筛选联动调 _loadDataareaOptions 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_flatmapExpenseItems/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兜底 bodyseq 字段用于 _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 | P1redirectTo 替代 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()
},
})