Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml
Neo 2dfc926f96 feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活
Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作:
- 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId
- 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设)
- xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离
- chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡)
- reference_card KPI 富卡接入 SSE 路径,db 真写入
- 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字

数据库:
- public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id
- biz.ai_run_logs 加 assistant_id + 复合索引
- chk_ai_cache_type CHECK 约束 8 类应用名
- cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation)
- 历史 emoji 抽取脚本 44/44 成功

后端 silent failure 修:
- cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效)
- _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema)
- task_manager talkingPoints 改 app5_tactics + tactics 字段
- task_manager aiSuggestion 改取 one_line_summary
- cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area
- WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞)
- internal_ai token 改 hmac.compare_digest

工具/文档:
- main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤
- 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则)
- 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错)
- audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务
- backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线

实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:07 +08:00

332 lines
16 KiB
Plaintext

<!-- pages/customer-detail/customer-detail.wxml -->
<wxs src="../../utils/format.wxs" module="fmt" />
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到客户信息</text>
</view>
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败</text>
<view class="retry-btn" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
</view>
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域 — SVG 做渐变底图 -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-dark-gold-aurora.svg" mode="widthFix" />
<view class="banner-overlay">
<!-- 客户头部信息 -->
<view class="customer-header">
<view class="avatar-box">
<text class="avatar-text">{{detail.avatarChar}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{detail.name}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? detail.phoneFull : detail.phone}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
</view>
</view>
</view>
<!-- Banner 统计 -->
<view class="banner-stats">
<view class="stat-item stat-border">
<text class="stat-value stat-green">{{fmt.money(detail.balance)}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{fmt.money(detail.consumption60d)}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{fmt.days(detail.idealInterval)}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{fmt.days(detail.daysSinceVisit)}}</text>
<text class="stat-label">距今到店</text>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content" >
<!-- AI 智能洞察(W1-AI-CLOSURE 复盘:cache miss 时整段不渲染,避免空白卡) -->
<view class="ai-insight-card" wx:if="{{aiInsight.summary || aiInsight.strategies.length > 0}}">
<view class="ai-insight-header">
<view class="ai-icon-box">
<image class="ai-icon-img" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
</view>
<text class="ai-insight-label">AI 智能洞察</text>
</view>
<view class="ai-insight-summary-v">
<text class="ai-insight-summary">{{fmt.safe(aiInsight.summary)}}</text>
</view>
<view class="ai-strategy-box">
<text class="strategy-title">当前推荐策略</text>
<view class="strategy-list">
<view class="strategy-item strategy-item-{{item.color}}" wx:for="{{aiInsight.strategies}}" wx:key="index" wx:if="{{index < aiInsight.strategies.length - 1}}">
<text class="strategy-text">{{item.text}}</text>
</view>
<view class="strategy-item strategy-item-{{item.color}} strategy-item-last" wx:for="{{aiInsight.strategies}}" wx:key="index" wx:if="{{index === aiInsight.strategies.length - 1}}">
<text class="strategy-text">{{item.text}}</text>
</view>
</view>
</view>
</view>
<!-- 维客线索 -->
<view class="card" wx:if="{{clues.length > 0}}">
<view class="card-header">
<text class="section-title title-green">维客线索</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<view class="clue-list">
<clue-card
wx:for="{{clues}}"
wx:key="index"
tag="{{item.tag}}"
category="{{item.tagColor}}"
emoji="{{item.emoji}}"
title="{{item.text}}"
source="{{item.source}}"
content="{{item.desc}}"
/>
</view>
</view>
<!-- 助教任务 -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">助教任务分配</text>
<text class="header-hint">当前进行中</text>
</view>
<view class="coach-task-list">
<view class="coach-task-card {{item.bgClass}}" wx:for="{{coachTasks}}" wx:key="index">
<view class="coach-task-top">
<view class="coach-name-row">
<heart-icon score="{{item.heartScore}}" />
<text class="coach-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<view class="coach-task-right">
<text class="coach-task-type type-{{item.taskColor}}">{{item.taskType}}</text>
<text class="coach-task-status status-{{item.status}}" wx:if="{{item.status !== 'normal'}}">
<text wx:if="{{item.status === 'pinned'}}">📌 置顶</text>
<text wx:elif="{{item.status === 'abandoned'}}">❌ 已放弃</text>
</text>
</view>
</view>
<text class="coach-last-service">上次服务:{{fmt.safe(item.lastService)}}</text>
<view class="coach-metrics">
<view class="coach-metric" wx:for="{{item.metrics}}" wx:for-item="m" wx:key="label">
<text class="metric-label">{{m.label}}</text>
<text class="metric-value {{m.color ? 'text-' + m.color : ''}}">{{fmt.safe(m.value)}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 最喜欢的助教 -->
<view class="card">
<view class="card-header">
<text class="section-title title-pink">最喜欢的助教</text>
<text class="header-hint">近60天</text>
</view>
<view class="fav-coach-list">
<!-- CHANGE 2026-03-29 | 前 3 个始终显示,其余折叠 -->
<block wx:for="{{favoriteCoaches}}" wx:key="index">
<view class="fav-coach-card {{item.bgClass}}" wx:if="{{index < 3 || favCoachExpanded}}">
<view class="fav-coach-top">
<view class="fav-coach-name">
<!-- CHANGE 2026-03-29 | 统一格式:爱心 - 名称 - 等级 -->
<heart-icon score="{{item.heartScore}}" />
<text class="fav-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<view class="fav-index">
<text class="fav-index-label">关系指数</text>
<text class="fav-index-value" style="color:{{item.indexColor}}">{{fmt.safe(item.relationIndex)}}</text>
</view>
</view>
<text class="fav-period">近60天</text>
<view class="fav-stats">
<view class="fav-stat" wx:for="{{item.stats}}" wx:for-item="s" wx:key="label">
<text class="fav-stat-label">{{s.label}}</text>
<text class="fav-stat-value {{s.color ? 'text-' + s.color : ''}}">{{fmt.safe(s.value)}}</text>
</view>
</view>
</view>
</block>
<!-- 展开/收起按钮 -->
<view class="expand-btn" wx:if="{{favoriteCoaches.length > 3}}" bindtap="onToggleFavCoaches">
<text class="expand-btn-text">{{favCoachExpanded ? '收起' : '展开更多 (' + (favoriteCoaches.length - 3) + ')'}}</text>
</view>
</view>
</view>
<!-- 消费记录 -->
<view class="card">
<view class="card-header" bindtap="onViewServiceRecords" hover-class="card-header--hover">
<text class="section-title title-orange">消费记录</text>
<t-icon name="chevron-right" size="40rpx" color="#a6a6a6" />
</view>
<view class="record-list" wx:if="{{consumptionRecords.length > 0}}">
<block wx:for="{{consumptionRecords}}" wx:key="id">
<!-- 台桌结账 -->
<view class="record-card" wx:if="{{item.type === 'table'}}">
<view class="record-card-header record-header-blue">
<view class="record-project">
<view class="record-dot record-dot-blue"></view>
<text class="record-project-name record-name-blue">{{fmt.safe(item.tableName)}}</text>
</view>
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-time-row">
<view class="record-time-left">
<text class="record-time-text">{{fmt.safe(item.startTime)}}</text>
<text class="record-time-arrow">→</text>
<text class="record-time-text">{{fmt.safe(item.endTime)}}</text>
<text class="record-duration-tag">{{fmt.safe(item.duration)}}</text>
</view>
<view class="record-fee-right">
<text class="record-fee-amount">{{fmt.money(item.tableFee)}}</text>
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">{{fmt.money(item.tableOrigPrice)}}</text>
</view>
</view>
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{fmt.safe(c.name)}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{fmt.safe(c.courseType)}} · {{fmt.hours(c.hours)}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{fmt.hours(c.perfHours)}}</text>
<text class="record-coach-fee">{{fmt.money(c.fee)}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<view class="record-food-right">
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
</view>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<view class="record-total-right">
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">{{fmt.money(item.totalOrigPrice)}}</text>
</view>
</view>
</view>
<!-- 商城订单 -->
<view class="record-card" wx:elif="{{item.type === 'shop'}}">
<view class="record-card-header record-header-green">
<view class="record-project">
<view class="record-dot record-dot-green"></view>
<text class="record-project-name record-name-green">商城订单</text>
</view>
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{fmt.safe(c.name)}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{fmt.safe(c.courseType)}} · {{fmt.hours(c.hours)}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{fmt.hours(c.perfHours)}}</text>
<text class="record-coach-fee">{{fmt.money(c.fee)}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
</view>
</view>
</block>
</view>
<view class="record-loading-more" wx:if="{{loadingMore}}">
<t-loading theme="circular" size="40rpx" text="加载更多..." />
</view>
<view class="record-empty" wx:if="{{consumptionRecords.length === 0 && !loadingMore}}">
<t-icon name="info-circle" size="80rpx" color="#dcdcdc" />
<text class="empty-hint">暂无消费记录</text>
</view>
</view>
<!-- 备注记录 -->
<view class="card">
<view class="card-header">
<text class="section-title title-orange">备注记录</text>
<text class="header-hint">共 {{sortedNotes.length}} 条</text>
</view>
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<text class="note-author">{{item.creatorName || item.tagLabel}}{{item.creatorRole ? ' · ' + item.creatorRole : ''}}</text>
<view class="note-top-right">
<text class="note-time">{{fmt.safe(item.createdAt)}}</text>
<view class="note-delete-btn" data-id="{{item.id}}" bindtap="onDeleteNote" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="32rpx" color="#c5c5c5" />
</view>
</view>
</view>
<text class="note-content">{{fmt.safe(item.content)}}</text>
</view>
</view>
<view class="note-empty" wx:else>
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
<text class="empty-hint">暂无备注</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-bar safe-area-bottom">
<view class="btn-chat" bindtap="onStartChat" hover-class="btn-chat--hover">
<t-icon name="chat" size="36rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
<t-icon name="edit-1" size="36rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<!-- 备注弹窗 -->
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" showExpandBtn="{{false}}" showRating="{{false}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage 让 chat 上下文捕获生效) -->
<ai-float-button customerId="{{detail.id}}" sourcePage="customer-detail" />
</block>
<dev-fab />