diff --git a/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts b/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts index 54cf533..82e519c 100644 --- a/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts +++ b/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts @@ -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 通过 循环渲染不同样式 +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 = { 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 = { + 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)