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>
This commit is contained in:
@@ -11,12 +11,45 @@
|
||||
|
||||
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
|
||||
import { getRandomAiColor } from '../../utils/ai-color'
|
||||
import { fetchBoardFinance } from '../../services/api'
|
||||
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
|
||||
@@ -73,7 +106,17 @@ Page({
|
||||
|
||||
compareEnabled: false,
|
||||
isCurrentMonth: true,
|
||||
aiInsights: [] as Array<{ icon: string; text: string }>,
|
||||
// 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: [
|
||||
@@ -453,8 +496,16 @@ Page({
|
||||
},
|
||||
})
|
||||
|
||||
const aiInsights = (data.aiInsights || []) as Array<{ icon: string; text: string }>
|
||||
this.setData({ aiInsights, pageState: 'normal' })
|
||||
// 后端不返回 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)
|
||||
@@ -465,6 +516,124 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user