feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复

涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
This commit is contained in:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View File

@@ -20,13 +20,47 @@ Component({
type: Number,
value: 200,
},
/**
* Phase 2.3来源页面标识sourcePage用于后端注入 page_context。
* 取值参考 backend page_context.py 的 SUPPORTED_PAGE_TYPES
* board-finance / board-coach / board-customer / performance / task-list / my-profile 等。
* 为空时不传入 chat 页。
*/
sourcePage: {
type: String,
value: '',
},
/**
* Phase 2.3页面筛选参数board-* 页面的 timeDimension/dimension/areaFilter 等),
* 将作为 JSON 字符串附加到 url在 chat 页面解析后随 SSE 请求发给后端。
*/
pageFilters: {
type: Object,
value: null as Record<string, string> | null,
},
},
methods: {
onTap() {
let url = this.data.targetUrl
const params: string[] = []
if (this.data.customerId) {
url += `?customerId=${this.data.customerId}`
params.push(`customerId=${encodeURIComponent(this.data.customerId)}`)
}
if (this.data.sourcePage) {
params.push(`sourcePage=${encodeURIComponent(this.data.sourcePage)}`)
}
if (this.data.pageFilters && Object.keys(this.data.pageFilters).length > 0) {
try {
params.push(
`pageFilters=${encodeURIComponent(JSON.stringify(this.data.pageFilters))}`,
)
} catch {
// 非法 filters 忽略,不影响跳转
}
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&')
}
wx.navigateTo({
url,

View File

@@ -203,6 +203,8 @@
<!-- AI 洞察 -->
<!-- CHANGE 2026-03-12 | intent: H5 原型使用 SVG 机器人图标,不可用 emoji 替代;规范要求内联 SVG 导出为文件用 image 引用 -->
<!-- CHANGE 2026-03-21 | P13 T6.1: AI 洞察改为动态渲染,移除硬编码文案 -->
<!-- CHANGE 2026-04-22 | AI 洞察改版两段式dim标题 + 正文第3条起省略为1行加"查看全部"按钮 + 覆盖大弹窗 -->
<!-- CHANGE 2026-04-22 seq11/12 置顶AI 洞察区首屏为"本期总结"(健康度 + 跟踪),下方为明细 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">
@@ -210,11 +212,108 @@
</view>
<text class="ai-insight-title">AI 智能洞察</text>
</view>
<view class="ai-insight-body" wx:if="{{aiInsights.length > 0}}">
<text class="ai-insight-line" wx:for="{{aiInsights}}" wx:key="index"><text class="ai-insight-dim">{{item.icon}} </text>{{item.text}}</text>
<!-- 本期总结卡片seq11(健康度评级) + seq12(跟踪指标) -->
<view class="ai-summary-card ai-summary-card--{{summaryLightType || 'neutral'}}" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<view class="ai-summary-head">
<view class="ai-summary-badge ai-summary-badge--{{summaryLightType || 'neutral'}}" wx:if="{{summaryLightLabel}}">
<text>{{summaryLightLabel}}</text>
</view>
<text class="ai-summary-head-title">本期总结</text>
</view>
<!-- 2026-04-22 v4evaluation title 与顶部徽章语义重复,隐藏 title 仅展示 body -->
<view class="ai-summary-block" wx:if="{{aiInsightSummary.evaluation}}">
<view class="ai-summary-block-body ai-summary-block-body-clamp">
<text wx:for="{{aiInsightSummary.evaluation.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-summary-divider" wx:if="{{aiInsightSummary.evaluation && aiInsightSummary.tracking}}"></view>
<view class="ai-summary-block ai-summary-block--tracking" wx:if="{{aiInsightSummary.tracking}}">
<text class="ai-summary-block-title">⏰ {{aiInsightSummary.tracking.title}}</text>
<view class="ai-summary-block-body ai-summary-block-body-clamp">
<text wx:for="{{aiInsightSummary.tracking.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</view>
<view class="ai-insight-body" wx:else>
<text class="ai-insight-line ai-insight-dim">暂无洞察数据</text>
<view class="ai-insight-body" wx:if="{{aiInsightDetails.length > 0}}">
<view class="ai-insight-details-label" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<text class="ai-insight-details-label-text">分板块明细洞察 · 仅展示前 3 条</text>
</view>
<!-- 2026-04-22 v3seq 1/2/3 统一展示"标题 + 单行省略正文",详情看弹窗 -->
<block wx:for="{{aiInsightDetails}}" wx:key="index" wx:if="{{index < 3}}">
<view class="ai-insight-item">
<view class="ai-insight-item-title">
<text class="ai-insight-item-seq">{{index + 1}}</text>
<text class="ai-insight-item-name">{{item.title}}</text>
</view>
<view class="ai-insight-item-body ai-insight-item-body-ellipsis">
<text wx:for="{{item.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</block>
<!-- 2026-04-22 v3只要有洞察就显示"查看全部"按钮,引导进弹窗看完整内容 -->
<view class="ai-insight-more" wx:if="{{aiInsightDetails.length > 0 || aiInsightSummary.evaluation}}" bindtap="openAllInsights" hover-class="ai-insight-more-hover">
<text class="ai-insight-more-text">查看全部 AI 洞察 </text>
</view>
</view>
<view class="ai-insight-body" wx:elif="{{!aiInsightSummary.evaluation && !aiInsightSummary.tracking}}">
<view class="ai-insight-item-body ai-insight-dim">暂无洞察数据</view>
</view>
</view>
<!-- AI 洞察全部查看弹窗:覆盖除底部 tab 外整个页面header / 可滚动 body / 底部通栏按钮 -->
<view class="ai-modal-mask" wx:if="{{aiInsightsModalVisible}}" bindtap="closeAllInsights" catchtouchmove="_noop">
<view class="ai-modal" catchtap="_noop">
<view class="ai-modal-header">
<view class="ai-modal-title-wrap">
<view class="ai-insight-icon">
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" class="ai-insight-icon-img" />
</view>
<text class="ai-modal-title">AI 智能洞察 · 共 {{aiInsights.length}} 条</text>
</view>
<view class="ai-modal-close" bindtap="closeAllInsights" hover-class="ai-modal-close-hover">
<text class="ai-modal-close-icon">✕</text>
</view>
</view>
<scroll-view scroll-y="{{true}}" class="ai-modal-body" enhanced="{{true}}" show-scrollbar="{{false}}" bounces="{{true}}">
<!-- 弹窗顶部本期总结seq11+seq12- 同款 summary-card -->
<view class="ai-summary-card ai-summary-card--{{summaryLightType || 'neutral'}} ai-summary-card--modal" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<view class="ai-summary-head">
<view class="ai-summary-badge ai-summary-badge--{{summaryLightType || 'neutral'}}" wx:if="{{summaryLightLabel}}">
<text>{{summaryLightLabel}}</text>
</view>
<text class="ai-summary-head-title">本期总结</text>
</view>
<view class="ai-summary-block" wx:if="{{aiInsightSummary.evaluation}}">
<view class="ai-summary-block-body">
<text wx:for="{{aiInsightSummary.evaluation.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-summary-divider" wx:if="{{aiInsightSummary.evaluation && aiInsightSummary.tracking}}"></view>
<view class="ai-summary-block ai-summary-block--tracking" wx:if="{{aiInsightSummary.tracking}}">
<text class="ai-summary-block-title">⏰ {{aiInsightSummary.tracking.title}}</text>
<view class="ai-summary-block-body">
<text wx:for="{{aiInsightSummary.tracking.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</view>
<view class="ai-modal-details-label" wx:if="{{(aiInsightSummary.evaluation || aiInsightSummary.tracking) && aiInsightDetails.length > 0}}">
<text class="ai-modal-details-label-text">分板块明细洞察</text>
</view>
<view class="ai-modal-item" wx:for="{{aiInsightDetails}}" wx:key="index">
<view class="ai-modal-item-title">
<text class="ai-modal-item-seq">{{index + 1}}</text>
<text class="ai-modal-item-name">{{item.title}}</text>
</view>
<view class="ai-modal-item-body">
<text wx:for="{{item.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-modal-footer-space"></view>
</scroll-view>
<view class="ai-modal-footer" bindtap="closeAllInsights" hover-class="ai-modal-footer-hover">关闭</view>
</view>
</view>
</view>

View File

@@ -492,7 +492,7 @@ AI_CHANGELOG
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 22rpx;
margin-bottom: 30rpx;
}
/* CHANGE 2026-03-12 | intent: H5 原型 AI 图标为 SVG 机器人24×24 → 42rpx不可用 emoji 替代 */
@@ -545,6 +545,357 @@ AI_CHANGELOG
color: rgba(255, 255, 255, 0.85);
}
/* CHANGE 2026-04-22 v2 | AI 洞察列表项:与弹窗同款(序号徽章 + 标题 + 缩进正文) */
.ai-insight-item {
padding: 6rpx 0 10rpx 0;
}
.ai-insight-item-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.ai-insight-item-seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 10rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 20rpx;
font-weight: 600;
}
.ai-insight-item-name {
font-size: 26rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-insight-item-body {
display: block;
font-size: 24rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.78);
text-indent: 48rpx;
}
.ai-insight-item-body-ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== 2026-04-22 seq11/12 置顶:本期总结(轻量版) ===== */
/* 不用卡片容器,直接嵌入 AI 洞察区,用彩色小点 + 淡分隔区分层级 */
.ai-summary-card {
margin: 0 24rpx 18rpx 0;
padding: 0 0 18rpx 0;
border-bottom: 2rpx dashed rgba(255, 255, 255, 0.1);
}
.ai-summary-card--modal {
margin: 0 0 14rpx 0;
}
/* 去掉左侧彩条,保留类名备用(无样式即不渲染) */
.ai-summary-card--green,
.ai-summary-card--yellow,
.ai-summary-card--red,
.ai-summary-card--neutral {
background: transparent;
}
.ai-summary-head {
display: flex;
align-items: baseline; /* 徽章与"本期总结"按文字基线对齐(字号不同时看起来贴底) */
gap: 10rpx;
margin-bottom: 14rpx;
}
/* 徽章:去胶囊底,纯色粗字强调三色灯级别 */
.ai-summary-badge {
display: inline-flex;
align-items: center;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 1rpx;
padding: 0;
background: transparent !important;
box-shadow: none !important;
}
.ai-summary-badge--green { color: #4ade80; }
.ai-summary-badge--yellow { color: #facc15; }
.ai-summary-badge--red { color: #f87171; }
.ai-summary-badge--neutral { color: rgba(255, 255, 255, 0.6); }
.ai-summary-head-title {
font-size: 22rpx;
font-weight: 400;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 1rpx;
}
.ai-summary-block {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ai-summary-block-title {
font-size: 25rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
line-height: 36rpx;
}
.ai-summary-block-body {
font-size: 24rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.72);
}
/* 2026-04-22 v3总结区 body 2 行省略,突出要点 */
.ai-summary-block-body-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-summary-block--tracking {
margin-top: 14rpx;
}
.ai-summary-block--tracking .ai-summary-block-title {
color: rgba(251, 191, 36, 0.85);
font-weight: 500;
}
.ai-summary-divider {
display: none;
}
/* "分板块明细洞察"分组标签 */
.ai-insight-details-label {
padding: 2rpx 0 12rpx 0;
}
.ai-insight-details-label-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.42);
letter-spacing: 1rpx;
}
.ai-modal-details-label {
padding: 6rpx 0 8rpx 0;
margin-bottom: 4rpx;
}
.ai-modal-details-label-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.42);
letter-spacing: 1rpx;
}
/* 2026-04-22 小程序 Markdown 内联样式:**加粗** / *倾斜* */
.md-seg {
display: inline;
}
.md-bold {
font-weight: 700;
color: rgba(255, 255, 255, 0.98);
}
.md-italic {
font-style: italic;
}
/* 加粗同时倾斜时组合生效class 拼接即可) */
/* CHANGE 2026-04-22 v2 | "查看全部" 按钮居中 */
.ai-insight-more {
margin: 10rpx 24rpx 0 0;
padding: 18rpx 24rpx;
text-align: center;
color: rgba(255, 255, 255, 0.88);
font-size: 26rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 12rpx;
}
.ai-insight-more-hover {
background: rgba(255, 255, 255, 0.12);
}
.ai-insight-more-text {
letter-spacing: 1rpx;
}
/* CHANGE 2026-04-22 | AI 全部洞察弹窗:覆盖除底部 tab 外整个页面 */
.ai-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 110rpx; /* 避让自定义 tabBar 约 110rpx */
bottom: calc(110rpx + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.55);
z-index: 9998;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
/* 2026-04-22 v5fixed top+bottom 固定高scroll-view 在 flex max-height 里渲染溢出,放弃 auto 高度) */
.ai-modal {
position: fixed;
left: 24rpx;
right: 24rpx;
top: 40rpx;
bottom: calc(150rpx + env(safe-area-inset-bottom)); /* 110rpx tab + 40rpx 留白 */
background: #2e2e2e;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.45);
z-index: 9999;
}
.ai-modal-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx 20rpx 28rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.08);
}
.ai-modal-title-wrap {
display: flex;
align-items: center;
gap: 14rpx;
flex: 1;
min-width: 0;
}
.ai-modal-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-modal-close {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
.ai-modal-close-hover {
background: rgba(255, 255, 255, 0.18);
}
.ai-modal-close-icon {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
line-height: 28rpx;
}
.ai-modal-body {
/* 2026-04-22 v5modal 固定高度后 flex:1 1 0 分配剩余空间给 scroll-view */
flex: 1 1 0;
min-height: 0;
padding: 20rpx 28rpx 12rpx 28rpx;
box-sizing: border-box;
}
.ai-modal-item {
padding: 22rpx 0 22rpx 0;
border-bottom: 2rpx dashed rgba(255, 255, 255, 0.1);
}
.ai-modal-item:last-of-type {
border-bottom: none;
}
.ai-modal-item-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 10rpx;
}
.ai-modal-item-seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 22rpx;
font-weight: 600;
}
.ai-modal-item-name {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
flex: 1;
}
.ai-modal-item-body {
display: block;
font-size: 26rpx;
line-height: 40rpx;
color: rgba(255, 255, 255, 0.78);
text-indent: 48rpx; /* 首行缩进,和 seq 徽章对齐阅读感 */
}
.ai-modal-footer-space {
height: 24rpx;
}
/* 2026-04-22 v3 | 弹窗底部整块作为关闭按钮:固定高度 100rpx贯通整宽 */
.ai-modal-footer {
flex-shrink: 0;
height: 100rpx;
line-height: 100rpx;
text-align: center;
font-size: 30rpx;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #667eea, #764ba2);
letter-spacing: 8rpx;
}
.ai-modal-footer-hover {
opacity: 0.82;
}
/* ===== 通用表格边框容器 ===== */
.table-bordered {
border: 2rpx solid #e7e7e7;

View File

@@ -198,6 +198,12 @@ Page({
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
/** SSE 断线重试次数 */
_sseRetryCount: 0,
/** SSE 最大自动重试次数 */
_SSE_MAX_RETRY: 2,
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat/chat')
@@ -227,10 +233,27 @@ Page({
this.loadMessagesByContext('coach', options.coachId)
} else if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
// Phase 2.3:优先解析 options.pageFiltersai-float-button 传入的 JSON 字符串),
// 回退到单独键旧入口兼容timeDimension / areaFilter 等)
const pageFilters: Record<string, string> = {}
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
if (options.pageFilters) {
try {
const parsed = JSON.parse(decodeURIComponent(options.pageFilters))
if (parsed && typeof parsed === 'object') {
for (const k of Object.keys(parsed)) {
const v = parsed[k]
if (v != null) pageFilters[k] = String(v)
}
}
} catch {
// JSON 解析失败忽略,回退到单键读取
}
}
if (Object.keys(pageFilters).length === 0) {
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
}
}
this.setData({ sourcePage: options.sourcePage, pageFilters })
this.loadMessagesByContext(options.sourcePage, '')
@@ -418,6 +441,7 @@ Page({
},
// onDone: 流结束,更新消息 ID 和时间
(messageId: number, createdAt: string) => {
this._sseRetryCount = 0
this.setData({
[`messages[${aiIndex}].id`]: String(messageId),
[`messages[${aiIndex}].timestamp`]: createdAt,
@@ -477,8 +501,20 @@ Page({
}
},
fail: () => {
// 网络错误或连接中断
if (this.data.isStreaming) {
// 网络错误或连接中断:无内容时指数退避重连
this._sseTask = null
if (!this.data.isStreaming) return
if (fullContent === '' && this._sseRetryCount < this._SSE_MAX_RETRY) {
this._sseRetryCount++
const delay = (2 ** this._sseRetryCount) * 1000
wx.showToast({ title: `重连中 ${this._sseRetryCount}/${this._SSE_MAX_RETRY}...`, icon: 'loading', duration: delay })
this.setData({
messages: this.data.messages.slice(0, aiIndex),
isStreaming: false,
streamingContent: '',
})
setTimeout(() => { this.triggerAIReply(chatId, content) }, delay)
} else {
const errorContent = fullContent || '连接中断,请重试'
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
@@ -487,7 +523,6 @@ Page({
})
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
}
this._sseTask = null
},
} as WechatMiniprogram.RequestOption)
@@ -509,4 +544,19 @@ Page({
}, 50)
}, 50)
},
/** 点击引用卡片跳转到对应详情页Phase 2.1 */
onRefCardTap(e: WechatMiniprogram.BaseEvent & { currentTarget: { dataset: { link?: string } } }) {
const link = e.currentTarget?.dataset?.link
if (!link || typeof link !== 'string') {
return
}
wx.navigateTo({
url: link,
fail: (err) => {
console.error('跳转引用详情失败', err)
wx.showToast({ title: '跳转失败', icon: 'none' })
},
})
},
})

View File

@@ -92,13 +92,18 @@
<text class="bubble-text">{{item.content}}</text>
</view>
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
<view class="inline-ref-card inline-ref-card--assistant" wx:if="{{item.referenceCard}}">
<view
class="inline-ref-card inline-ref-card--assistant {{item.referenceCard.link ? 'inline-ref-card--link' : ''}}"
wx:if="{{item.referenceCard}}"
data-link="{{item.referenceCard.link}}"
bindtap="onRefCardTap"
>
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : item.referenceCard.type === 'assistant' ? '🧑\u200d🏫 助教' : item.referenceCard.type === 'task' ? '📋 任务' : '📋 记录'}}</text>
<text class="inline-ref-title">{{fmt.safe(item.referenceCard.title)}}</text>
</view>
<text class="inline-ref-summary">{{fmt.safe(item.referenceCard.summary)}}</text>
<view class="inline-ref-data">
<view class="inline-ref-data" wx:if="{{item.referenceCard.dataList.length > 0}}">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{fmt.safe(entry.key)}}</text>
<text class="ref-data-value">{{fmt.safe(entry.value)}}</text>

View File

@@ -4,7 +4,7 @@
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerDetail } from '../../services/api'
import { fetchCustomerDetail, fetchAICache } from '../../services/api'
interface ConsumptionRecord {
id: string
@@ -132,6 +132,7 @@ Page({
}
}
this.setData({ pageState: 'normal' })
if (id) this._loadAIInsight(id)
} catch (e) {
console.error('[customer-detail] loadDetail 失败:', e)
this.setData({ pageState: 'error' })
@@ -140,6 +141,23 @@ Page({
}
},
async _loadAIInsight(memberId: string) {
const cache = await fetchAICache('app7_customer_analysis', memberId)
if (!cache?.result_json) return
const rj = cache.result_json as any
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
const strategies = Array.isArray(rj.strategies)
? rj.strategies.map((s: any, i: number) => ({
color: COLORS[i % COLORS.length],
text: s.title || s.text || '',
}))
: []
this.setData({
'aiInsight.summary': rj.summary || '',
'aiInsight.strategies': strategies,
})
},
onRetry() {
const id = this.data.detail?.id || ''
this.loadDetail(id)

View File

@@ -5,6 +5,8 @@
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerConsumptionRecords } from '../../services/api'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
Page({
data: {
@@ -38,11 +40,12 @@ Page({
monthLoading: false,
},
onLoad(options) {
async onLoad(options) {
const id = options?.customerId || options?.id || ''
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 默认当前年月走业务时钟sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
const currentYear = clock.business_year
const currentMonth = clock.business_month
this.setData({
customerId: id,
currentYear,

View File

@@ -5,6 +5,8 @@
| 2026-03-27 | 任务A 前端改造 | 修复数据转换duration/income/timeRange/table/recordType去掉 loadCustomerInfo 改从 records 响应取客户信息,新增 monthIncome 展示 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
// CHANGE 2026-03-27 | 任务A A5: 去掉 fetchCustomerDetail客户信息从 fetchCustomerRecords 响应中获取
import { fetchCustomerRecords } from '../../services/api'
import { formatCount } from '../../utils/money'
@@ -86,12 +88,12 @@ Page({
monthLoading: false,
},
onLoad(options) {
async onLoad(options) {
const id = options?.customerId || options?.id || ''
// 默认当前年月
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 默认当前年月走业务时钟sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
const currentYear = clock.business_year
const currentMonth = clock.business_month
this.setData({
customerId: id,
currentYear,

View File

@@ -11,6 +11,8 @@ import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { API_BASE } from '../../utils/config'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
const COURSE_TAG_MAP: Record<string, string> = {
@@ -58,7 +60,7 @@ Page({
coachRole: '',
storeName: '',
/** 月份切换 */
/** 月份切换onLoad 中改写为业务时钟当前年月) */
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
@@ -83,12 +85,13 @@ Page({
hasMore: false,
},
onLoad() {
const now = new Date()
async onLoad() {
// CHANGE 2026-05-02 | 用业务时钟初始化年月sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
this.setData({
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
currentYear: clock.business_year,
currentMonth: clock.business_month,
monthLabel: `${clock.business_year}${clock.business_month}`,
})
this.loadBanner()
this.loadData()
@@ -140,11 +143,13 @@ Page({
wx.showLoading({ title: '加载中...', mask: true })
// 预估规则:当月且当前日期 ≤ 5号全小程序统一
const now = new Date()
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式按 sandbox_date 判断
const clock = await getBusinessClock()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const isCurrentMonth = currentYear === clock.business_year
&& currentMonth === clock.business_month
&& businessDay <= 5
try {
const res = await fetchPerformanceRecords({
@@ -243,7 +248,7 @@ Page({
},
/** 切换月份 */
switchMonth(e: WechatMiniprogram.TouchEvent) {
async switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
@@ -255,11 +260,13 @@ Page({
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
}
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式下不允许"翻到 sandbox_date 之后"
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && businessDay <= 5
// 月份切换重置分页到第 1 页
this.setData({

View File

@@ -10,6 +10,8 @@ import { fetchMe, fetchPerformanceOverview } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
const COURSE_TAG_MAP: Record<string, string> = {
@@ -118,15 +120,16 @@ Page({
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
// G2当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | G2 当月预估判断改用业务时钟sandbox 模式按 sandbox_date 判断)
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
// TODO: 联调时从接口参数或页面参数获取 year/month
const year = nowYear
const month = nowMonth
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"
const isCurrentMonth = year === nowYear && month === nowMonth && now.getDate() <= 5
const isCurrentMonth = year === nowYear && month === nowMonth && businessDay <= 5
try {
// 并行请求用户信息和绩效概览

View File

@@ -20,6 +20,8 @@ import { fetchTasks, fetchMe, pinTask, unpinTask, abandonTask, restoreTask, crea
import { formatMoney } from '../../utils/money'
import { formatDeadline } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
import {
@@ -386,9 +388,11 @@ Page({
}
// G2: 当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式按 sandbox_date 判断
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const incomeMonth = perfData.incomeMonth
let dataYear = nowYear
let dataMonth = nowMonth
@@ -397,7 +401,7 @@ Page({
if (parts) dataMonth = parseInt(parts[1], 10)
}
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"(全小程序统一)
const isCurrentMonth = dataYear === nowYear && dataMonth === nowMonth && now.getDate() <= 5
const isCurrentMonth = dataYear === nowYear && dataMonth === nowMonth && businessDay <= 5
this.setData({
pageState: totalCount > 0 ? 'normal' : 'empty',

View File

@@ -35,6 +35,31 @@ export async function fetchMe(): Promise<ApiUserInfo> {
return request({ url: '/api/xcx/me', method: 'GET', needAuth: true })
}
// ============================================
// 业务时钟(沙箱支持)
// ============================================
export interface RuntimeClock {
mode: 'live' | 'sandbox'
business_date: string // YYYY-MM-DD
business_year: number
business_month: number
business_year_month: string // YYYY-MM
business_now: string
is_sandbox: boolean
sandbox_date: string | null
sandbox_instance_id: string | null
}
/**
* 获取当前门店的业务时钟live 真实日sandbox 模拟日)。
* 沙箱模式下,小程序所有依赖"当前年月"的请求都应使用此结果,
* 避免直接 ``new Date()`` 导致与后端 sandbox_date 不一致。
*/
export async function fetchRuntimeClock(): Promise<RuntimeClock> {
return request({ url: '/api/xcx/runtime/clock', method: 'GET', needAuth: true })
}
// ============================================
// 任务模块
// ============================================
@@ -413,6 +438,26 @@ export async function sendChatMessage(chatId: string, content: string): Promise<
// 配置模块
// ============================================
/** AI 缓存查询Phase 2.5 */
export async function fetchAICache(cacheType: string, targetId: string): Promise<{
result_json: Record<string, any> | null;
score: number | null;
} | null> {
try {
const data = await request({
url: `/api/ai/cache/${cacheType}`,
method: 'GET',
data: { target_id: targetId },
needAuth: true,
})
if (!data) return null
const d = data as any
return { result_json: d.result_json ?? null, score: d.score ?? null }
} catch {
return null
}
}
/** 项目类型筛选器列表CONFIG-1 */
// CHANGE 2026-03-20 | R3 修复value 改为数据库 category_codefallback 与后端一致
export async function fetchSkillTypes(): Promise<Array<{ value: string; text: string; icon?: string }>> {

View File

@@ -0,0 +1,83 @@
// 业务时钟缓存
//
// sandbox 模式下,小程序的 performance / task-list / customer-records 等页面
// 需要按"业务日"而不是"真实今天"构造请求参数。
//
// 用法:
// import { getBusinessClock, getBusinessYearMonth } from '../../utils/runtime-clock'
// const clock = await getBusinessClock()
// wx.request({ url: ..., data: { year: clock.business_year, month: clock.business_month } })
//
// 缓存策略:
// - 单例 in-memory cache最多 60 秒;过期后自动重新拉取。
// - 切换沙箱后,建议页面调用 `clearBusinessClockCache()` 主动失效。
import { fetchRuntimeClock, type RuntimeClock } from '../services/api'
const TTL_MS = 60_000 // 60 秒缓存,足以覆盖一次页面进入
let cached: { value: RuntimeClock; ts: number } | null = null
let inflight: Promise<RuntimeClock> | null = null
/** 主动清空业务时钟缓存(沙箱切换、登出后调用)。 */
export function clearBusinessClockCache(): void {
cached = null
inflight = null
}
/** 拉取业务时钟(可能命中缓存)。失败时降级为本地"今天"。 */
export async function getBusinessClock(force = false): Promise<RuntimeClock> {
const now = Date.now()
if (!force && cached && now - cached.ts < TTL_MS) {
return cached.value
}
if (inflight) {
return inflight
}
inflight = (async () => {
try {
const clock = await fetchRuntimeClock()
cached = { value: clock, ts: Date.now() }
return clock
} catch (err) {
console.warn('[runtime-clock] 拉取业务时钟失败,降级为本地时间', err)
return localFallback()
} finally {
inflight = null
}
})()
return inflight
}
/** 便捷方法:返回业务年月 (YYYY-MM)。 */
export async function getBusinessYearMonth(): Promise<{ year: number; month: number; label: string }> {
const clock = await getBusinessClock()
return {
year: clock.business_year,
month: clock.business_month,
label: `${clock.business_year}${clock.business_month}`,
}
}
/** 便捷方法:返回业务日 (YYYY-MM-DD)。 */
export async function getBusinessDate(): Promise<string> {
return (await getBusinessClock()).business_date
}
function localFallback(): RuntimeClock {
const d = new Date()
const year = d.getFullYear()
const month = d.getMonth() + 1
const ymd = `${year}-${String(month).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
return {
mode: 'live',
business_date: ymd,
business_year: year,
business_month: month,
business_year_month: `${year}-${String(month).padStart(2, '0')}`,
business_now: d.toISOString(),
is_sandbox: false,
sandbox_date: null,
sandbox_instance_id: null,
}
}