feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs

This commit is contained in:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "申请访问权限",
"navigationStyle": "custom",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,146 @@
import { request } from "../../utils/request"
Page({
data: {
statusBarHeight: 0,
siteCode: "",
role: "",
phone: "",
employeeNumber: "",
nickname: "",
submitting: false,
},
onLoad() {
const { statusBarHeight } = wx.getWindowInfo()
this.setData({ statusBarHeight })
},
onShow() {
this._checkAccess()
},
/** 校验用户身份:无 token 跳登录,非 new/rejected 跳对应页 */
async _checkAccess() {
const token = wx.getStorageSync("token")
if (!token) {
wx.reLaunch({ url: "/pages/login/login" })
return
}
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
status: data.status,
nickname: data.nickname,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.status)
switch (data.status) {
case "new":
break
case "rejected":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
case "approved":
wx.reLaunch({ url: "/pages/mvp/mvp" })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
break
case "disabled":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
}
} catch {
// 网络错误不阻塞,允许用户继续填表
}
},
onBack() {
wx.navigateBack({ fail: () => wx.reLaunch({ url: "/pages/login/login" }) })
},
/* 原生 input 的 bindinput 事件 */
onSiteCodeInput(e: WechatMiniprogram.Input) {
this.setData({ siteCode: e.detail.value })
},
onRoleInput(e: WechatMiniprogram.Input) {
this.setData({ role: e.detail.value })
},
onPhoneInput(e: WechatMiniprogram.Input) {
this.setData({ phone: e.detail.value })
},
onEmployeeNumberInput(e: WechatMiniprogram.Input) {
this.setData({ employeeNumber: e.detail.value })
},
onNicknameInput(e: WechatMiniprogram.Input) {
this.setData({ nickname: e.detail.value })
},
async onSubmit() {
if (this.data.submitting) return
const { siteCode, role, phone, nickname, employeeNumber } = this.data
if (!siteCode.trim()) {
wx.showToast({ title: "请输入球房ID", icon: "none" })
return
}
if (!role.trim()) {
wx.showToast({ title: "请输入申请身份", icon: "none" })
return
}
if (!/^\d{11}$/.test(phone)) {
wx.showToast({ title: "请输入11位手机号", icon: "none" })
return
}
if (!nickname.trim()) {
wx.showToast({ title: "请输入昵称", icon: "none" })
return
}
this.setData({ submitting: true })
try {
await request({
url: "/api/xcx/apply",
method: "POST",
data: {
site_code: siteCode.trim(),
applied_role_text: role.trim(),
phone,
employee_number: employeeNumber.trim() || undefined,
nickname: nickname.trim(),
},
needAuth: true,
})
wx.showToast({ title: "申请已提交", icon: "success" })
setTimeout(() => {
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
}, 800)
} catch (err: any) {
const msg =
err?.data?.detail ||
(err?.statusCode === 409
? "您已有待审核的申请"
: err?.statusCode === 422
? "表单信息有误,请检查"
: "提交失败,请稍后重试")
wx.showToast({ title: msg, icon: "none" })
} finally {
this.setData({ submitting: false })
}
},
})

View File

@@ -0,0 +1,107 @@
<!-- pages/apply/apply.wxml — 按 H5 原型结构迁移 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 顶部导航栏 -->
<view class="navbar">
<view class="navbar-back" bindtap="onBack">
<t-icon name="chevron-left" size="35rpx" color="#4b4b4b" />
</view>
<text class="navbar-title">申请访问权限</text>
</view>
<!-- 主体内容 -->
<view class="main">
<!-- 欢迎卡片 + 审核流程(整合) -->
<view class="welcome-card">
<view class="welcome-header">
<view class="welcome-icon-box">
<t-icon name="check-circle-filled" size="42rpx" color="#fff" />
</view>
<view class="welcome-text">
<text class="welcome-title">欢迎加入球房运营助手</text>
<text class="welcome-desc">请填写申请信息,等待管理员审核</text>
</view>
</view>
<!-- 审核流程步骤条 -->
<view class="steps-bar">
<view class="steps-row">
<view class="step-item">
<view class="step-circle step-circle--active">1</view>
<text class="step-label step-label--active">提交申请</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">等待审核</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">审核通过</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">4</view>
<text class="step-label">开始使用</text>
</view>
</view>
</view>
</view>
<!-- 表单区域 -->
<view class="form-card">
<!-- 球房ID -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>球房ID</text>
</view>
<input class="form-input" type="text" placeholder="请输入球房ID" value="{{siteCode}}" maxlength="5" bindinput="onSiteCodeInput" />
</view>
<!-- 申请身份 -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>申请身份</text>
</view>
<input class="form-input" type="text" placeholder="请输入申请身份(如:助教、店长等)" value="{{role}}" bindinput="onRoleInput" />
</view>
<!-- 手机号 -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>手机号</text>
</view>
<input class="form-input" type="number" placeholder="请输入手机号" value="{{phone}}" maxlength="11" bindinput="onPhoneInput" />
</view>
<!-- 编号(选填) -->
<view class="form-item form-item--border">
<view class="form-label">
<text>编号</text>
<text class="optional-tag">选填</text>
</view>
<input class="form-input" type="text" placeholder="请输入编号" value="{{employeeNumber}}" maxlength="50" bindinput="onEmployeeNumberInput" />
</view>
<!-- 昵称 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>昵称</text>
</view>
<input class="form-input" type="text" placeholder="请输入昵称" value="{{nickname}}" maxlength="50" bindinput="onNicknameInput" />
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-area">
<text class="form-tip">请认真填写,信息不完整可能导致审核不通过</text>
<view class="submit-btn {{submitting ? 'submit-btn--disabled' : ''}}" bindtap="onSubmit">
<t-loading wx:if="{{submitting}}" theme="circular" size="32rpx" color="#fff" />
<text wx:else class="submit-btn-text">提交申请</text>
</view>
<text class="bottom-tip">审核通常需要 1-3 个工作日</text>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,271 @@
/* pages/apply/apply.wxss — H5 px × 2 × 0.875 精确转换 */
.page {
min-height: 100vh;
background: linear-gradient(135deg, var(--primary-shadow-minimal) 0%, var(--color-primary-light) 50%, var(--primary-shadow-minimal) 100%);
display: flex;
flex-direction: column;
box-sizing: border-box;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
/* ---- 顶部导航栏 h-11=44px→78rpx ---- */
.navbar {
height: 78rpx;
display: flex;
align-items: center;
justify-content: center;
position: sticky;
top: 0;
z-index: 10;
background: var(--color-white-overlay-light);
border-bottom: 1rpx solid var(--border-light);
}
.navbar-back {
position: absolute;
left: 28rpx;
padding: 8rpx;
}
/* text-base=16px→28rpx */
.navbar-title {
font-size: 28rpx;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.5rpx;
}
/* ---- 主体内容 p-4=16px→28rpx ---- */
.main {
flex: 1;
padding: 28rpx;
padding-bottom: 380rpx;
}
/* ---- 欢迎卡片 p-5=20px→36rpx, rounded-2xl=16px→28rpx ---- */
.welcome-card {
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
border-radius: 28rpx;
padding: 36rpx;
margin-bottom: 28rpx;
box-shadow: 0 14rpx 36rpx var(--color-primary-shadow-light);
}
/* gap-4=16px→28rpx, mb-4=16px→28rpx */
.welcome-header {
display: flex;
align-items: center;
gap: 28rpx;
margin-bottom: 28rpx;
}
/* w-12 h-12=48px→84rpx, rounded-xl=12px→22rpx */
.welcome-icon-box {
width: 84rpx;
height: 84rpx;
min-width: 84rpx;
background: var(--color-white-overlay-lighter);
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-text {
display: flex;
flex-direction: column;
gap: 8rpx;
}
/* text-lg=18px→32rpx */
.welcome-title {
font-size: 32rpx;
font-weight: 600;
color: var(--color-white);
}
/* text-sm=14px→24rpx */
.welcome-desc {
font-size: 24rpx;
color: var(--color-white-overlay-light);
font-weight: 300;
}
/* ---- 审核流程步骤条 p-4=16px→28rpx, rounded-xl=12px→22rpx ---- */
.steps-bar {
background: var(--color-white-overlay-minimal);
border-radius: 22rpx;
padding: 28rpx;
}
.steps-row {
display: flex;
align-items: center;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
/* w-7 h-7=28px→50rpx, text-xs=12px→22rpx */
.step-circle {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
background: var(--color-white-overlay-lighter);
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
color: var(--color-white-overlay-light);
}
.step-circle--active {
background: var(--color-white);
color: var(--color-primary);
font-weight: 600;
}
/* text-xs=12px→22rpx */
.step-label {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
}
.step-label--active {
color: rgba(255, 255, 255, 0.9);
}
/* h-0.5=2px→4rpx, mx-2=8px→14rpx */
.step-line {
flex: 1;
height: 4rpx;
background: rgba(255, 255, 255, 0.3);
margin: 0 10rpx;
margin-bottom: 24rpx;
}
/* ---- 表单卡片 rounded-2xl=16px→28rpx ---- */
.form-card {
background: var(--bg-secondary);
border-radius: 28rpx;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
/* px-5=20px→36rpx, py-4=16px→28rpx+2rpx 视觉补偿 */
.form-item {
padding: 30rpx 36rpx;
}
.form-item--border {
border-bottom: 2rpx solid var(--bg-tertiary);
}
/* text-sm=14px→24rpx, mb-2=8px→14rpx */
.form-label {
display: flex;
align-items: center;
gap: 4rpx;
margin-bottom: 14rpx;
font-size: 24rpx;
font-weight: 500;
color: var(--text-primary);
}
/* text-sm=14px→24rpx */
.required {
color: var(--color-error);
font-size: 24rpx;
}
/* text-xs=12px→22rpx */
.optional-tag {
font-size: 20rpx;
color: var(--text-tertiary);
font-weight: 400;
margin-left: 10rpx;
}
/* px-4=16px→28rpx, py-3=12px→22rpx, rounded-xl=12px→22rpx, text-sm=14px→24rpx
小程序 input 组件内部有压缩py 加 4rpx 补偿到视觉等高 */
.form-input {
width: 100%;
height: 80rpx;
padding: 0 28rpx;
background: var(--bg-tertiary);
border-radius: 22rpx;
border: 2rpx solid var(--bg-tertiary);
font-size: 24rpx;
font-weight: 300;
color: var(--text-primary);
box-sizing: border-box;
}
.form-input::placeholder {
color: var(--text-disabled);
font-weight: 300;
}
/* ---- 表单提示(移入底部固定区) ---- */
.form-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: var(--text-tertiary);
margin-bottom: 18rpx;
font-weight: 300;
}
/* ---- 底部提交 p-4=16px→28rpx, pb-8=32px→56rpx ---- */
.bottom-area {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 28rpx;
padding-bottom: calc(56rpx + env(safe-area-inset-bottom));
background: var(--color-white-overlay-light);
border-top: 2rpx solid var(--bg-tertiary);
z-index: 10;
}
/* py-4=16px→28rpx (用padding代替固定高度), rounded-xl=12px→22rpx, text-base=16px→28rpx */
.submit-btn {
width: 100%;
padding: 28rpx 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
border-radius: 22rpx;
box-shadow: 0 10rpx 28rpx var(--color-primary-shadow);
}
.submit-btn--disabled {
opacity: 0.6;
}
/* text-base=16px→28rpx */
.submit-btn-text {
font-size: 28rpx;
font-weight: 500;
color: var(--color-white);
}
/* text-xs=12px→22rpx, mt-3=12px→22rpx */
.bottom-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: var(--text-disabled);
margin-top: 22rpx;
font-weight: 300;
}

View File

@@ -0,0 +1,17 @@
{
"navigationBarTitleText": "助教看板",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/custom-tab-bar/index",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-tag": "tdesign-miniprogram/tag/tag",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,289 @@
// 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片
// TODO: 联调时替换 mock 数据为真实 API 调用
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
export {}
/** 排序维度 → 卡片模板映射 */
type DimType = 'perf' | 'salary' | 'sv' | 'task'
const SORT_TO_DIM: Record<string, DimType> = {
perf_desc: 'perf',
perf_asc: 'perf',
salary_desc: 'salary',
salary_asc: 'salary',
sv_desc: 'sv',
task_desc: 'task',
}
const SORT_OPTIONS = [
{ value: 'perf_desc', text: '定档业绩最高' },
{ value: 'perf_asc', text: '定档业绩最低' },
{ value: 'salary_desc', text: '工资最高' },
{ value: 'salary_asc', text: '工资最低' },
{ value: 'sv_desc', text: '客源储值最高' },
{ value: 'task_desc', text: '任务完成最多' },
]
const SKILL_OPTIONS = [
{ value: 'all', text: '不限' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
]
const TIME_OPTIONS = [
{ value: 'month', text: '本月' },
{ value: 'quarter', text: '本季度' },
{ value: 'last_month', text: '上月' },
{ value: 'last_3m', text: '前3个月(不含本月)' },
{ value: 'last_quarter', text: '上季度' },
{ value: 'last_6m', text: '最近6个月(不含本月,不支持客源储值最高)' },
]
/** 等级 → 样式类映射 */
const LEVEL_CLASS: Record<string, string> = {
star: 'level--star',
senior: 'level--high',
middle: 'level--mid',
junior: 'level--low',
// 兼容旧中文 key 过渡期
'星级': 'level--star',
'高级': 'level--high',
'中级': 'level--mid',
'初级': 'level--low',
}
/** 技能 → 样式类映射 */
const SKILL_CLASS: Record<string, string> = {
'🎱': 'skill--chinese',
'斯': 'skill--snooker',
'🀄': 'skill--mahjong',
'🎤': 'skill--karaoke',
}
interface CoachItem {
id: string
name: string
initial: string
avatarGradient: string
level: string // 英文 key: star | senior | middle | junior
levelClass: string
skills: Array<{ text: string; cls: string }>
topCustomers: string[]
// 定档业绩维度
perfHours: number
perfHoursBefore?: number
perfGap?: string
perfReached: boolean
// 工资维度
salary: number
salaryPerfHours: number
salaryPerfBefore?: number
// 客源储值维度
svAmount: number
svCustomerCount: number
svConsume: number
// 任务维度
taskRecall: number
taskCallback: number
}
/** Mock 数据(忠于 H5 原型 6 位助教) */
const MOCK_COACHES: CoachItem[] = [
{
id: 'c1', name: '小燕', initial: '小',
avatarGradient: 'blue',
level: 'star', levelClass: LEVEL_CLASS['star'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💖 王先生', '💖 李女士', '💛 赵总'],
perfHours: 86.2, perfHoursBefore: 92.0, perfGap: '距升档 13.8h', perfReached: false,
salary: 12680, salaryPerfHours: 86.2, salaryPerfBefore: 92.0,
svAmount: 45200, svCustomerCount: 18, svConsume: 8600,
taskRecall: 18, taskCallback: 14,
},
{
id: 'c2', name: '泡芙', initial: '泡',
avatarGradient: 'green',
level: 'senior', levelClass: LEVEL_CLASS['senior'],
skills: [{ text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 陈先生', '💛 刘女士', '💛 黄总'],
perfHours: 72.5, perfHoursBefore: 78.0, perfGap: '距升档 7.5h', perfReached: false,
salary: 10200, salaryPerfHours: 72.5, salaryPerfBefore: 78.0,
svAmount: 38600, svCustomerCount: 15, svConsume: 6200,
taskRecall: 15, taskCallback: 13,
},
{
id: 'c3', name: 'Lucy', initial: 'A',
avatarGradient: 'pink',
level: 'star', levelClass: LEVEL_CLASS['star'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 张先生', '💛 周女士', '💛 吴总'],
perfHours: 68.0, perfHoursBefore: 72.5, perfGap: '距升档 32.0h', perfReached: false,
salary: 9800, salaryPerfHours: 68.0, salaryPerfBefore: 72.5,
svAmount: 32100, svCustomerCount: 14, svConsume: 5800,
taskRecall: 12, taskCallback: 13,
},
{
id: 'c4', name: 'Mia', initial: 'M',
avatarGradient: 'amber',
level: 'middle', levelClass: LEVEL_CLASS['middle'],
skills: [{ text: '🀄', cls: SKILL_CLASS['🀄'] }],
topCustomers: ['💛 赵先生', '💛 吴女士', '💛 孙总'],
perfHours: 55.0, perfGap: '距升档 5.0h', perfReached: false,
salary: 7500, salaryPerfHours: 55.0,
svAmount: 28500, svCustomerCount: 12, svConsume: 4100,
taskRecall: 10, taskCallback: 10,
},
{
id: 'c5', name: '糖糖', initial: '糖',
avatarGradient: 'violet',
level: 'junior', levelClass: LEVEL_CLASS['junior'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💛 钱先生', '💛 孙女士', '💛 周总'],
perfHours: 42.0, perfHoursBefore: 45.0, perfReached: true,
salary: 6200, salaryPerfHours: 42.0, salaryPerfBefore: 45.0,
svAmount: 22000, svCustomerCount: 10, svConsume: 3500,
taskRecall: 8, taskCallback: 10,
},
{
id: 'c6', name: '露露', initial: '露',
avatarGradient: 'cyan',
level: 'middle', levelClass: LEVEL_CLASS['middle'],
skills: [{ text: '🎤', cls: SKILL_CLASS['🎤'] }],
topCustomers: ['💛 郑先生', '💛 冯女士', '💛 陈总'],
perfHours: 38.0, perfGap: '距升档 22.0h', perfReached: false,
salary: 5100, salaryPerfHours: 38.0,
svAmount: 18300, svCustomerCount: 9, svConsume: 2800,
taskRecall: 6, taskCallback: 9,
},
]
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'coach' as 'finance' | 'customer' | 'coach',
selectedSort: 'perf_desc',
sortOptions: SORT_OPTIONS,
selectedSkill: 'all',
skillOptions: SKILL_OPTIONS,
selectedTime: 'month',
timeOptions: TIME_OPTIONS,
/** 当前维度类型,控制卡片模板 */
dimType: 'perf' as DimType,
coaches: [] as CoachItem[],
allCoaches: [] as CoachItem[],
/** 筛选栏可见性(滚动隐藏/显示) */
filterBarVisible: true,
},
_lastScrollTop: 0,
_scrollAcc: 0,
_scrollDir: null as 'up' | 'down' | null,
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 滚动隐藏/显示筛选栏 */
onPageScroll(e: { scrollTop: number }) {
const y = e.scrollTop
const delta = y - this._lastScrollTop
this._lastScrollTop = y
if (y <= 8) {
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
this._scrollAcc = 0
this._scrollDir = null
return
}
if (Math.abs(delta) <= 2) return
const dir = delta > 0 ? 'down' : 'up'
if (dir !== this._scrollDir) {
this._scrollDir = dir
this._scrollAcc = 0
}
this._scrollAcc += Math.abs(delta)
const threshold = dir === 'up' ? 12 : 24
if (this._scrollAcc < threshold) return
const visible = dir === 'up'
if (this.data.filterBarVisible !== visible) {
this.setData({ filterBarVisible: visible })
}
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
const data = MOCK_COACHES
if (!data || data.length === 0) {
this.setData({ pageState: 'empty' })
return
}
// 格式化数字字段为展示字符串
const enriched = data.map((c) => ({
...c,
perfHoursLabel: formatHours(c.perfHours),
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
salaryLabel: formatMoney(c.salary),
salaryPerfHoursLabel: formatHours(c.salaryPerfHours),
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
svAmountLabel: formatMoney(c.svAmount),
svCustomerCountLabel: formatCount(c.svCustomerCount, '人'),
svConsumeLabel: formatMoney(c.svConsume),
taskRecallLabel: formatCount(c.taskRecall, '次'),
taskCallbackLabel: formatCount(c.taskCallback, '次'),
}))
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
}, 400)
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'finance') {
wx.switchTab({ url: '/pages/board-finance/board-finance' })
} else if (tab === 'customer') {
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
}
},
onSortChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const val = e.detail.value
this.setData({
selectedSort: val,
dimType: SORT_TO_DIM[val] || 'perf',
})
},
onSkillChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedSkill: e.detail.value })
},
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedTime: e.detail.value })
},
onRetry() {
this.loadData()
},
onCoachTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/coach-detail/coach-detail?id=' + id })
},
})

View File

@@ -0,0 +1,136 @@
<!-- 助教看板页 — 忠于 H5 原型结构 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无助教数据" />
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">点击重试</view>
</view>
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab -->
<view class="board-tabs">
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
<text>财务</text>
</view>
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
<text>客户</text>
</view>
<view class="board-tab board-tab--active" data-tab="coach">
<text>助教</text>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
<view class="filter-bar-inner">
<view class="filter-item filter-item--wide">
<filter-dropdown label="定档业绩最高" options="{{sortOptions}}" value="{{selectedSort}}" bind:change="onSortChange" />
</view>
<view class="filter-item">
<filter-dropdown label="不限" options="{{skillOptions}}" value="{{selectedSkill}}" bind:change="onSkillChange" />
</view>
<view class="filter-item">
<filter-dropdown label="本月" options="{{timeOptions}}" value="{{selectedTime}}" bind:change="onTimeChange" />
</view>
</view>
</view>
<!-- 助教列表 -->
<view class="coach-list">
<view class="coach-card" wx:for="{{coaches}}" wx:key="id" data-id="{{item.id}}" bindtap="onCoachTap" hover-class="coach-card--hover">
<view class="card-row">
<!-- 头像 -->
<view class="card-avatar avatar-{{item.avatarGradient}}">
<text class="avatar-text">{{item.initial}}</text>
</view>
<!-- 信息区 -->
<view class="card-info">
<!-- 第一行:姓名 + 等级 + 技能 + 右侧指标 -->
<view class="card-name-row">
<text class="card-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
<text class="skill-tag {{skill.cls}}" wx:for="{{item.skills}}" wx:for-item="skill" wx:key="text">{{skill.text}}</text>
<!-- 定档业绩维度 -->
<view class="card-right" wx:if="{{dimType === 'perf'}}">
<text class="right-text">定档 <text class="right-highlight">{{item.perfHoursLabel}}</text></text>
<text class="right-sub" wx:if="{{item.perfHoursBeforeLabel}}">折前 <text class="right-sub-val">{{item.perfHoursBeforeLabel}}</text></text>
</view>
<!-- 工资维度 -->
<view class="card-right" wx:elif="{{dimType === 'salary'}}">
<text class="salary-tag">预估</text>
<text class="salary-amount">{{item.salaryLabel}}</text>
</view>
<!-- 客源储值维度 -->
<view class="card-right" wx:elif="{{dimType === 'sv'}}">
<text class="right-sub">储值</text>
<text class="salary-amount">{{item.svAmountLabel}}</text>
</view>
<!-- 任务维度 -->
<view class="card-right" wx:elif="{{dimType === 'task'}}">
<text class="right-text">召回 <text class="right-highlight">{{item.taskRecallLabel}}</text></text>
</view>
</view>
<!-- 第二行:客户列表 + 右侧补充 -->
<view class="card-bottom-row">
<view class="customer-list">
<block wx:for="{{item.topCustomers}}" wx:for-item="cust" wx:for-index="custIdx" wx:key="*this">
<text class="customer-item" wx:if="{{dimType !== 'sv' || custIdx < 2}}">{{cust}}</text>
</block>
</view>
<!-- 定档业绩:距升档/已达标 -->
<text class="bottom-right bottom-right--warning" wx:if="{{dimType === 'perf' && !item.perfReached}}">{{item.perfGap}}</text>
<text class="bottom-right bottom-right--success" wx:elif="{{dimType === 'perf' && item.perfReached}}">✅ 已达标</text>
<!-- 工资:定档/折前 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'salary'}}">
<text class="bottom-perf">定档 <text class="bottom-perf-val">{{item.salaryPerfHoursLabel}}</text></text>
<text class="bottom-sub" wx:if="{{item.salaryPerfBeforeLabel}}">折前 <text class="bottom-sub-val">{{item.salaryPerfBeforeLabel}}</text></text>
</view>
<!-- 客源储值:客户数 | 消耗 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'sv'}}">
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{item.svCustomerCountLabel}}</text></text>
<text class="bottom-divider">|</text>
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{item.svConsumeLabel}}</text></text>
</view>
<!-- 任务:回访数 -->
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{item.taskCallbackLabel}}</text></text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区(为自定义导航栏留空间) -->
<view class="safe-bottom"></view>
</block>
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 — 在导航栏上方 -->
<ai-float-button />
<dev-fab wx:if="{{false}}" />

View File

@@ -0,0 +1,338 @@
/* 助教看板页 — H5 px × 2 × 0.875 精确转换 */
/* ===== 三态 ===== */
.page-loading,
.page-empty,
.page-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 60vh;
}
.retry-btn {
margin-top: 24rpx;
padding: 16rpx 48rpx;
font-size: 28rpx;
color: #0052d9;
border: 2rpx solid #0052d9;
border-radius: 44rpx;
}
/* ===== 看板 Tab py-3=12px→22rpx, text-sm=14px→26rpx(视觉校准+2) ===== */
.board-tabs {
display: flex;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
position: sticky;
top: 0;
z-index: 20;
}
/* text-sm=14px→24rpx, line-height 匹配 Tailwind 默认 1.25rem=20px→36rpx */
.board-tab {
flex: 1;
text-align: center;
padding: 22rpx 0;
font-size: 24rpx;
line-height: 34rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
}
.board-tab--active {
color: #0052d9;
font-weight: 600;
}
/* w-24px→42rpx, h-3px→5rpxH5 实际渲染偏细) */
.board-tab--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 42rpx;
height: 5rpx;
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 3rpx;
}
/* px-4=16px→28rpx, py-2=8px→14rpx, sticky top-[44px]→77rpx */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 77rpx;
z-index: 15;
border-bottom: 2rpx solid #eeeeee;
transition: transform 220ms ease, opacity 220ms ease;
}
.filter-bar--hidden {
opacity: 0;
transform: translateY(-110%);
pointer-events: none;
}
/* p-1.5=6px→10rpx, gap-2=8px→14rpx, rounded-lg=8px→14rpx */
.filter-bar-inner {
display: flex;
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 16rpx;
padding: 10rpx;
border: 2rpx solid #eeeeee;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.filter-item {
flex: 1;
min-width: 0;
}
.filter-item--wide {
flex: 2;
}
/* p-4=16px→28rpx, space-y-3=12px→22rpx — 四边一致 */
.coach-list {
padding: 13rpx 28rpx 28rpx 28rpx;
}
/* p-4=16px→28rpx, rounded-2xl=16px→32rpxborder-radius: px×2不乘0.875 */
.coach-card {
background: #ffffff;
border-radius: 32rpx;
padding: 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.coach-card--hover {
opacity: 0.96;
transform: scale(0.98);
}
/* gap-3=12px→20rpx, items-center 忠于 H5 原型 */
.card-row {
display: flex;
align-items: center;
gap: 20rpx;
}
/* w-11 h-11=44px→78rpx, text-base=16px→28rpx — items-center 对齐,无需 margin-top */
.card-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
/* 头像渐变色由 app.wxss 全局 .avatar-{key} 统一提供VI §8 */
/* ===== 信息区 ===== */
.card-info {
flex: 1;
min-width: 0;
}
/* gap-1.5=6px→10rpx */
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
flex-wrap: nowrap;
}
/* text-base=16px→28rpx, line-height: 1.5→42rpx匹配 Tailwind 默认) */
.card-name {
font-size: 28rpx;
line-height: 42rpx;
font-weight: 600;
color: #242424;
flex-shrink: 0;
}
/* ===== 等级标签 px-1.5=6px→10rpx, py-0.5=2px→4rpx, text-xs=12px→22rpx ===== */
.level-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 8rpx;
flex-shrink: 0;
font-weight: 500;
}
.level--star {
background: linear-gradient(to right, #fbbf24, #f97316);
color: #ffffff;
}
.level--high {
background: linear-gradient(to right, #a78bfa, #8b5cf6);
color: #ffffff;
}
.level--mid {
background: linear-gradient(to right, #60a5fa, #6366f1);
color: #ffffff;
}
.level--low {
background: linear-gradient(to right, #9ca3af, #6b7280);
color: #ffffff;
}
/* ===== 技能标签 ===== */
.skill-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.skill--chinese { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
.skill--snooker { background: rgba(0, 168, 112, 0.1); color: #00a870; }
.skill--mahjong { background: rgba(237, 123, 47, 0.1); color: #ed7b2f; }
.skill--karaoke { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
/* ml-auto, gap-2=8px→14rpx */
.card-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 14rpx;
flex-shrink: 0;
white-space: nowrap;
}
/* text-xs=12px→22rpx — "定档"标签文字 */
.right-text {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 400;
}
/* text-sm=14px→24rpx — 定档数值加粗 */
.right-highlight {
font-size: 24rpx;
font-weight: 700;
color: #0052d9;
}
/* "折前"/"储值" 辅助文字 — text-xs=12px→22rpx, gray-6=#a6a6a6 */
.right-sub {
font-size: 22rpx;
color: #a6a6a6;
font-weight: 400;
}
.right-sub-val {
color: #8b8b8b;
font-weight: 400;
}
/* 工资维度 */
.salary-tag {
padding: 4rpx 10rpx;
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
font-size: 22rpx;
border-radius: 6rpx;
}
/* text-lg=18px→32rpx, line-height: 28px→50rpx匹配 Tailwind text-lg 的 1.75rem */
.salary-amount {
font-size: 32rpx;
line-height: 50rpx;
font-weight: 700;
color: #242424;
}
/* mt-1.5=6px→10rpx, text-xs=12px→22rpx */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10rpx;
}
/* gap-2=8px→14rpx */
.customer-list {
display: flex;
align-items: center;
gap: 14rpx;
overflow: hidden;
flex: 1;
min-width: 0;
}
.customer-item {
font-size: 22rpx;
color: #a6a6a6;
white-space: nowrap;
}
.bottom-right {
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.bottom-right--warning {
color: #ed7b2f;
}
.bottom-right--success {
color: #00a870;
}
.bottom-right-group {
display: flex;
align-items: center;
gap: 10rpx;
flex-shrink: 0;
}
.bottom-perf {
font-size: 22rpx;
color: #4b4b4b;
font-weight: 600;
}
.bottom-perf-val {
font-weight: 700;
color: #4b4b4b;
}
.bottom-sub {
font-size: 22rpx;
color: #8b8b8b;
}
.bottom-sub-val {
color: #8b8b8b;
}
.bottom-divider {
font-size: 22rpx;
color: #c5c5c5;
}
/* ===== 底部安全区(自定义导航栏高度 100rpx + safe-area ===== */
.safe-bottom {
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -0,0 +1,17 @@
{
"navigationBarTitleText": "客户看板",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"heart-icon": "/components/heart-icon/heart-icon",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/custom-tab-bar/index",
"dev-fab": "/components/dev-fab/dev-fab",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,306 @@
// 客户看板页 — 8 个维度查看前 100 名客户
// TODO: 联调时替换 mock 数据为真实 API 调用
import { initPageAiColor } from '../../utils/ai-color-manager'
export {}
/** 维度类型 → 卡片模板映射 */
type DimType = 'recall' | 'potential' | 'balance' | 'recharge' | 'recent' | 'spend60' | 'freq60' | 'loyal'
const DIMENSION_TO_DIM: Record<string, DimType> = {
recall: 'recall',
potential: 'potential',
balance: 'balance',
recharge: 'recharge',
recent: 'recent',
spend60: 'spend60',
freq60: 'freq60',
loyal: 'loyal',
}
const DIMENSION_OPTIONS = [
{ value: 'recall', text: '最应召回' },
{ value: 'potential', text: '最大消费潜力' },
{ value: 'balance', text: '最高余额' },
{ value: 'recharge', text: '最近充值' },
{ value: 'recent', text: '最近到店' },
{ value: 'spend60', text: '最高消费 近60天' },
{ value: 'freq60', text: '最频繁 近60天' },
{ value: 'loyal', text: '最专一 近60天' },
]
const PROJECT_OPTIONS = [
{ value: 'all', text: '全部' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
]
interface AssistantInfo {
name: string
cls: string // assistant--assignee / assistant--abandoned / assistant--normal
heartScore: number // 0-10heart-icon 组件用
badge?: string // 跟 / 弃
badgeCls?: string // assistant-badge--follow / assistant-badge--drop
}
interface CustomerItem {
id: string
name: string
initial: string
avatarCls: string
// 召回维度
idealDays: number
elapsedDays: number
overdueDays: number
visits30d: number
balance: string
recallIndex: string
// 消费潜力维度
potentialTags: Array<{ text: string; theme: string }>
spend30d: string
avgVisits: string
avgSpend: string
// 余额维度
lastVisit: string
monthlyConsume: string
availableMonths: string
// 充值维度
lastRecharge: string
rechargeAmount: string
recharges60d: string
currentBalance: string
// 消费60天
spend60d: string
visits60d: string
highSpendTag: boolean
// 频率60天
avgInterval: string
weeklyVisits: Array<{ val: number; pct: number }>
// 专一度
intimacy: string
coachName: string
coachRatio: string
topCoachName: string
topCoachHeart: number
topCoachScore: string
coachDetails: Array<{
name: string
cls: string
heartScore: number
badge?: string
avgDuration: string
serviceCount: string
coachSpend: string
relationIdx: number
}>
// 最近到店
visitFreq: string
daysAgo: number
// 助教
assistants: AssistantInfo[]
}
/** Mock 数据(忠于 H5 原型 3 位客户) */
const MOCK_CUSTOMERS: CustomerItem[] = [
{
id: 'u1', name: '王先生', initial: '王', avatarCls: 'avatar--amber',
idealDays: 7, elapsedDays: 15, overdueDays: 8,
visits30d: 3, balance: '¥2,680', recallIndex: '9.2',
potentialTags: [
{ text: '高频', theme: 'primary' },
{ text: '高客单', theme: 'warning' },
],
spend30d: '¥4,200', avgVisits: '6.2次', avgSpend: '¥680',
lastVisit: '3天前', monthlyConsume: '¥3,500', availableMonths: '约0.8个月',
lastRecharge: '2月15日', rechargeAmount: '¥5,000', recharges60d: '2次', currentBalance: '¥2,680',
spend60d: '¥8,400', visits60d: '12', highSpendTag: true,
avgInterval: '5.0天', intimacy: '92',
topCoachName: '小燕', topCoachHeart: 9.2, topCoachScore: '9.2',
coachDetails: [
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '跟', avgDuration: '2.3h', serviceCount: '14', coachSpend: '¥4,200', relationIdx: 9.2 },
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5, avgDuration: '1.5h', serviceCount: '8', coachSpend: '¥2,100', relationIdx: 7.2 },
],
weeklyVisits: [
{ val: 2, pct: 60 }, { val: 2, pct: 60 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
{ val: 2, pct: 60 }, { val: 3, pct: 100 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
], coachName: '小燕', coachRatio: '78%',
visitFreq: '6.2次/月',
daysAgo: 3,
assistants: [
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '跟', badgeCls: 'assistant-badge--follow' },
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5 },
],
},
{
id: 'u2', name: '李女士', initial: '李', avatarCls: 'avatar--pink',
idealDays: 10, elapsedDays: 22, overdueDays: 12,
visits30d: 1, balance: '¥8,200', recallIndex: '8.5',
potentialTags: [
{ text: '高余额', theme: 'success' },
{ text: '低频', theme: 'gray' },
],
spend30d: '¥1,800', avgVisits: '2.1次', avgSpend: '¥860',
lastVisit: '12天前', monthlyConsume: '¥1,800', availableMonths: '约4.6个月',
lastRecharge: '1月20日', rechargeAmount: '¥10,000', recharges60d: '1次', currentBalance: '¥8,200',
spend60d: '¥3,600', visits60d: '4', highSpendTag: false,
avgInterval: '15.0天', intimacy: '68',
topCoachName: 'Lucy', topCoachHeart: 8.5, topCoachScore: '8.5',
coachDetails: [
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', avgDuration: '1.8h', serviceCount: '10', coachSpend: '¥3,600', relationIdx: 8.5 },
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', avgDuration: '0.8h', serviceCount: '3', coachSpend: '¥600', relationIdx: 4.2 },
],
weeklyVisits: [
{ val: 1, pct: 40 }, { val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 },
{ val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 }, { val: 1, pct: 40 },
], coachName: 'Lucy', coachRatio: '62%',
visitFreq: '2.1次/月',
daysAgo: 12,
assistants: [
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', badgeCls: 'assistant-badge--drop' },
],
},
{
id: 'u3', name: '张先生', initial: '张', avatarCls: 'avatar--blue',
idealDays: 5, elapsedDays: 8, overdueDays: 3,
visits30d: 5, balance: '¥1,200', recallIndex: '7.8',
potentialTags: [
{ text: '高频', theme: 'primary' },
],
spend30d: '¥3,500', avgVisits: '8.0次', avgSpend: '¥440',
lastVisit: '1天前', monthlyConsume: '¥3,200', availableMonths: '约0.4个月',
lastRecharge: '3月1日', rechargeAmount: '¥3,000', recharges60d: '3次', currentBalance: '¥1,200',
spend60d: '¥7,000', visits60d: '16', highSpendTag: true,
avgInterval: '3.8天', intimacy: '95',
topCoachName: '泡芙', topCoachHeart: 9.5, topCoachScore: '9.5',
coachDetails: [
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', avgDuration: '2.1h', serviceCount: '11', coachSpend: '¥3,300', relationIdx: 9.5 },
{ name: '小燕', cls: 'assistant--normal', heartScore: 6.8, avgDuration: '1.3h', serviceCount: '6', coachSpend: '¥1,800', relationIdx: 6.8 },
{ name: 'Lucy', cls: 'assistant--abandoned', heartScore: 3.2, badge: '弃', avgDuration: '0.5h', serviceCount: '2', coachSpend: '¥400', relationIdx: 3.2 },
],
weeklyVisits: [
{ val: 2, pct: 50 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 4, pct: 100 },
{ val: 3, pct: 75 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 3, pct: 75 },
], coachName: '泡芙', coachRatio: '85%',
visitFreq: '8.0次/月',
daysAgo: 1,
assistants: [
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
],
},
]
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'customer' as 'finance' | 'customer' | 'coach',
selectedDimension: 'recall',
dimensionOptions: DIMENSION_OPTIONS,
selectedProject: 'all',
projectOptions: PROJECT_OPTIONS,
/** 当前维度类型,控制卡片模板 */
dimType: 'recall' as DimType,
customers: [] as CustomerItem[],
allCustomers: [] as CustomerItem[],
totalCount: 0,
/** 筛选栏可见性 */
filterBarVisible: true,
},
_lastScrollTop: 0,
_scrollAcc: 0,
_scrollDir: null as 'up' | 'down' | null,
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 滚动隐藏/显示筛选栏 */
onPageScroll(e: { scrollTop: number }) {
const y = e.scrollTop
const delta = y - this._lastScrollTop
this._lastScrollTop = y
if (y <= 8) {
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
this._scrollAcc = 0
this._scrollDir = null
return
}
if (Math.abs(delta) <= 2) return
const dir = delta > 0 ? 'down' : 'up'
if (dir !== this._scrollDir) {
this._scrollDir = dir
this._scrollAcc = 0
}
this._scrollAcc += Math.abs(delta)
const threshold = dir === 'up' ? 12 : 24
if (this._scrollAcc < threshold) return
const visible = dir === 'up'
if (this.data.filterBarVisible !== visible) {
this.setData({ filterBarVisible: visible })
}
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
const data = MOCK_CUSTOMERS
if (!data || data.length === 0) {
this.setData({ pageState: 'empty' })
return
}
this.setData({
allCustomers: data,
customers: data,
totalCount: data.length,
pageState: 'normal',
})
}, 400)
},
onRetry() {
this.loadData()
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'finance') {
wx.switchTab({ url: '/pages/board-finance/board-finance' })
} else if (tab === 'coach') {
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
}
},
onDimensionChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const val = e.detail.value
this.setData({
selectedDimension: val,
dimType: DIMENSION_TO_DIM[val] || 'recall',
})
},
onProjectChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedProject: e.detail.value })
},
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/customer-detail/customer-detail?id=' + id })
},
})

View File

@@ -0,0 +1,311 @@
<!-- 客户看板页 — 忠于 H5 原型8 维度卡片模板 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无客户数据" />
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">点击重试</view>
</view>
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab -->
<view class="board-tabs">
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
<text>财务</text>
</view>
<view class="board-tab board-tab--active" data-tab="customer">
<text>客户</text>
</view>
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
<text>助教</text>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
<view class="filter-bar-inner">
<view class="filter-item filter-item--wide">
<filter-dropdown
label="最应召回"
options="{{dimensionOptions}}"
value="{{selectedDimension}}"
bind:change="onDimensionChange"
/>
</view>
<view class="filter-item">
<filter-dropdown
label="全部"
options="{{projectOptions}}"
value="{{selectedProject}}"
bind:change="onProjectChange"
/>
</view>
</view>
</view>
<!-- 列表头部 -->
<view class="list-header">
<view class="list-header-left">
<text class="list-header-title">客户列表</text>
<text class="list-header-sub">· 前100名</text>
</view>
<text class="list-header-count">共{{totalCount}}名客户</text>
</view>
<!-- 客户列表 -->
<view class="customer-list">
<view
class="customer-card"
hover-class="customer-card--hover"
wx:for="{{customers}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCustomerTap"
>
<!-- ===== 卡片头部:头像 + 姓名 + 右侧指标 ===== -->
<view class="card-header {{dimType === 'recall' || dimType === 'freq60' || dimType === 'recent' ? '' : 'card-header--lg'}}">
<view class="card-avatar {{item.avatarCls}}">
<text class="avatar-text">{{item.initial}}</text>
</view>
<text class="card-name" wx:if="{{dimType !== 'freq60'}}">{{item.name}}</text>
<!-- 最频繁:姓名 + 下方小字垂直排列 -->
<view class="card-name-group" wx:if="{{dimType === 'freq60'}}">
<text class="card-name">{{item.name}}</text>
<view class="card-name-sub">
<text class="mid-text" style="white-space: nowrap;">平均间隔 <text class="mid-primary">{{item.avgInterval}}</text></text>
<text class="mid-text" style="white-space: nowrap;">60天消费 <text class="mid-dark">{{item.spend60d}}</text></text>
</view>
</view>
<view class="card-header-spacer"></view>
<!-- 最应召回:理想/已过/超期 -->
<view class="header-metrics" wx:if="{{dimType === 'recall'}}">
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
<text class="metric-gray">已过 <text class="metric-error">{{item.elapsedDays}}天</text></text>
<view class="overdue-tag {{item.overdueDays > 7 ? 'overdue-tag--danger' : 'overdue-tag--warn'}}">
<text>超期 {{item.overdueDays}}天</text>
</view>
</view>
<!-- 最大消费潜力:频率/客单/余额标签 -->
<view class="header-metrics" wx:elif="{{dimType === 'potential'}}">
<view class="potential-tag potential-tag--{{tag.theme}}" wx:for="{{item.potentialTags}}" wx:for-item="tag" wx:key="text">
<text>{{tag.text}}</text>
</view>
</view>
<!-- 最高余额:最近到店/理想 -->
<view class="header-metrics" wx:elif="{{dimType === 'balance'}}">
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
</view>
<!-- 最近充值:最近到店/理想 -->
<view class="header-metrics" wx:elif="{{dimType === 'recharge'}}">
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
</view>
<!-- 最频繁近60天右上角大字到店次数 -->
<view class="header-metrics header-metrics--freq" wx:elif="{{dimType === 'freq60'}}">
<view class="freq-big-num">
<text class="freq-big-val">{{item.visits60d}}</text>
<text class="freq-big-unit">次</text>
</view>
<text class="freq-big-label">60天到店</text>
</view>
<!-- 最专一近60天右上角 ❤️ 助教名 + 关系指数 -->
<view class="header-metrics header-metrics--loyal" wx:elif="{{dimType === 'loyal'}}">
<heart-icon score="{{item.topCoachHeart}}" />
<text class="loyal-top-name">{{item.topCoachName}}</text>
<text class="loyal-top-score">{{item.topCoachScore}}</text>
</view>
<!-- 最高消费近60天高消费标签 -->
<view class="header-metrics" wx:elif="{{dimType === 'spend60'}}">
<view class="potential-tag potential-tag--warning" wx:if="{{item.highSpendTag}}">
<text>高消费</text>
</view>
</view>
<!-- 最近到店:右上角大字 X天前到店 -->
<view class="header-metrics header-metrics--recent" wx:elif="{{dimType === 'recent'}}">
<view class="recent-big-num">
<text class="recent-big-val">{{item.daysAgo}}</text>
<text class="recent-big-unit">天前到店</text>
</view>
</view>
</view>
<!-- ===== 卡片中间行:维度特定数据 ===== -->
<!-- 最应召回30天到店 / 余额 / 召回指数 -->
<view class="card-mid-row" wx:if="{{dimType === 'recall'}}">
<text class="mid-text">30天到店 <text class="mid-dark">{{item.visits30d}}次</text></text>
<text class="mid-text mid-ml">余额 <text class="mid-dark">{{item.balance}}</text></text>
<text class="mid-text mid-right">召回指数 <text class="mid-primary-bold">{{item.recallIndex}}</text></text>
</view>
<!-- 最大消费潜力4 列网格30天消费用橙色大字和最高余额的余额值一致 -->
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'potential'}}">
<view class="grid-cell">
<text class="grid-label">30天消费</text>
<text class="grid-val grid-val--lg">{{item.spend30d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均到店</text>
<text class="grid-val">{{item.avgVisits}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">余额</text>
<text class="grid-val grid-val--success">{{item.balance}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
</view>
</view>
<!-- 最高余额3 列网格 -->
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'balance'}}">
<view class="grid-cell">
<text class="grid-label">余额</text>
<text class="grid-val grid-val--warning grid-val--lg">{{item.balance}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均消耗</text>
<text class="grid-val">{{item.monthlyConsume}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">可用</text>
<text class="grid-val grid-val--success">{{item.availableMonths}}</text>
</view>
</view>
<!-- 最近充值4 列网格 -->
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'recharge'}}">
<view class="grid-cell">
<text class="grid-label">最后充值</text>
<text class="grid-val">{{item.lastRecharge}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">充值</text>
<text class="grid-val grid-val--success">{{item.rechargeAmount}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">60天充值</text>
<text class="grid-val">{{item.recharges60d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">当前余额</text>
<text class="grid-val grid-val--warning">{{item.currentBalance}}</text>
</view>
</view>
<!-- 最高消费近60天3 列网格 -->
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'spend60'}}">
<view class="grid-cell">
<text class="grid-label">近60天消费</text>
<text class="grid-val grid-val--warning grid-val--lg">{{item.spend60d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">到店次数</text>
<text class="grid-val">{{item.visits60d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
</view>
</view>
<!-- 最频繁近60天无中间行数据已在头部 -->
<!-- 最频繁迷你柱状图8 周) -->
<view class="mini-chart" wx:if="{{dimType === 'freq60'}}">
<view class="mini-chart-header">
<text class="mini-chart-label">8周前</text>
<text class="mini-chart-label">本周</text>
</view>
<view class="mini-chart-bars">
<view class="mini-bar-col" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">
<view class="mini-bar" style="height:{{wv.pct}}%;opacity:{{0.2 + wIdx * 0.057}}"></view>
</view>
</view>
<view class="mini-chart-nums">
<text class="mini-chart-num {{wIdx === item.weeklyVisits.length - 1 ? 'mini-chart-num--active' : ''}}" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">{{wv.val}}</text>
</view>
</view>
<!-- 最专一近60天助教服务明细表 -->
<view class="loyal-table" wx:elif="{{dimType === 'loyal'}}">
<!-- 表头 -->
<view class="loyal-row loyal-row--header">
<text class="loyal-col loyal-col--name">助教</text>
<text class="loyal-col">次均时长</text>
<text class="loyal-col">服务次数</text>
<text class="loyal-col">助教消费</text>
<text class="loyal-col">关系指数</text>
</view>
<!-- 数据行 -->
<view class="loyal-row" wx:for="{{item.coachDetails}}" wx:for-item="cd" wx:key="name">
<view class="loyal-col loyal-col--name">
<heart-icon score="{{cd.heartScore}}" />
<text class="loyal-coach-name {{cd.cls}}">{{cd.name}}</text>
<text class="assistant-badge assistant-badge--follow" wx:if="{{cd.badge === '跟'}}">跟</text>
<text class="assistant-badge assistant-badge--drop" wx:elif="{{cd.badge === '弃'}}">弃</text>
</view>
<text class="loyal-col loyal-val">{{cd.avgDuration}}</text>
<text class="loyal-col loyal-val">{{cd.serviceCount}}</text>
<text class="loyal-col loyal-val">{{cd.coachSpend}}</text>
<text class="loyal-col {{cd.relationIdx >= 8 ? 'loyal-val--primary' : 'loyal-val--gray'}}">{{cd.relationIdx}}</text>
</view>
</view>
<!-- 最近到店:理想间隔 / 60天到店 / 次均消费 -->
<view class="card-mid-row" wx:elif="{{dimType === 'recent'}}">
<text class="mid-text">理想间隔 <text class="mid-dark">{{item.idealDays}}天</text></text>
<text class="mid-text mid-ml">60天到店 <text class="mid-dark">{{item.visits60d}}天</text></text>
<text class="mid-text mid-ml-right">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
</view>
<!-- ===== 卡片底部:助教行(最专一维度不显示,因为助教信息在表格中) ===== -->
<view class="card-assistant-row" wx:if="{{item.assistants && item.assistants.length > 0 && dimType !== 'loyal'}}">
<text class="assistant-label">助教:</text>
<block wx:for="{{item.assistants}}" wx:for-item="ast" wx:for-index="astIdx" wx:key="name">
<text class="assistant-sep" wx:if="{{astIdx > 0}}">|</text>
<view class="assistant-tag">
<heart-icon score="{{ast.heartScore}}" />
<text class="assistant-name {{ast.cls}}">{{ast.name}}</text>
<text class="assistant-badge assistant-badge--follow" wx:if="{{ast.badge === '跟'}}">跟</text>
<text class="assistant-badge assistant-badge--drop" wx:elif="{{ast.badge === '弃'}}">弃</text>
</view>
</block>
</view>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</block>
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 -->
<ai-float-button />
<dev-fab wx:if="{{false}}" />

View File

@@ -0,0 +1,692 @@
/* 客户看板页 — 忠于 H5 原型87.5% 缩放 */
/* ===== 三态 ===== */
.page-loading,
.page-empty,
.page-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 60vh;
}
.retry-btn {
margin-top: 24rpx;
padding: 16rpx 48rpx;
font-size: 28rpx;
line-height: 51rpx; /* 修复text-lg line-height 28px → 51rpx */
color: #0052d9;
border: 2rpx solid #0052d9;
border-radius: 44rpx;
}
/* ===== 看板 Tab对齐 board-coach 规范) ===== */
.board-tabs {
display: flex;
background: #ffffff;
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
position: sticky;
top: 0;
z-index: 20;
}
.board-tab {
flex: 1;
text-align: center;
padding: 22rpx 0;
font-size: 26rpx; /* P1-6: 修正 14px → 26rpx */
line-height: 36rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
}
.board-tab--active {
color: #0052d9;
font-weight: 600;
}
.board-tab--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 44rpx; /* 补充修正24px → 44rpx */
height: 6rpx; /* 补充修正3px → 6rpx */
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 4rpx; /* 补充修正2px → 4rpx */
}
/* ===== 筛选区域(对齐 board-coach 规范) ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 30rpx; /* P0-3: 修正 16px → 30rpx */
position: sticky;
top: 70rpx;
z-index: 15;
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
transition: transform 220ms ease, opacity 220ms ease;
}
.filter-bar--hidden {
opacity: 0;
transform: translateY(-110%);
pointer-events: none;
}
.filter-bar-inner {
display: flex;
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 30rpx; /* P0-2: 修正 16px → 30rpx */
padding: 12rpx; /* 修正 6px → 12rpx */
border: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.filter-item {
flex: 1;
min-width: 0;
}
.filter-item--wide {
flex: 1;
}
/* ===== 列表头部 ===== */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 36rpx 14rpx; /* P0-1: 修正 */
}
.list-header-left {
display: flex;
align-items: center;
gap: 14rpx; /* P1-11: 修正 8px → 14rpx */
}
.list-header-title {
font-size: 30rpx; /* P1-7: 修正 16px → 30rpx */
line-height: 44rpx; /* 修正text-base line-height 24px → 44rpx */
font-weight: 600;
color: #242424;
}
.list-header-sub {
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #a6a6a6;
}
.list-header-count {
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #c5c5c5;
}
/* ===== 客户列表 ===== */
.customer-list {
padding: 0 30rpx 30rpx; /* P0-4: 修正 */
margin-top: 8rpx; /* 修正 4px → 8rpx */
}
/* ===== 客户卡片 ===== */
.customer-card {
background: #ffffff;
border-radius: 30rpx; /* P2-15: 修正 16px → 30rpx */
padding: 29rpx; /* 精确修正16px → 29rpx而非 30rpx*/
margin-bottom: 25rpx; /* 补充修正12px → 22rpx */
box-shadow: 0 3rpx 8rpx rgba(0, 0, 0, 0.1);
}
.customer-card--hover {
opacity: 0.96;
transform: scale(0.98);
}
/* ===== 卡片头部 ===== */
.card-header {
display: flex;
align-items: center;
gap: 14rpx;
}
/* 网格/表格维度头部间距更大 */
.card-header--lg {
margin-bottom: 2rpx; /* H5 mb-3=12px → 22rpx */
}
.card-avatar {
width: 66rpx; /* 补充修正36px → 66rpx */
height: 66rpx; /* 补充修正36px → 66rpx */
border-radius: 22rpx; /* 补充修正12px → 22rpx */
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
color: #ffffff;
font-size: 26rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx原为36rpx差异17.6%*/
font-weight: 600;
}
/* 头像渐变色 */
.avatar--amber { background: linear-gradient(135deg, #fbbf24, #f97316); }
.avatar--pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar--blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar--green { background: linear-gradient(135deg, #4ade80, #10b981); }
.avatar--purple { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.avatar--cyan { background: linear-gradient(135deg, #22d3ee, #14b8a6); }
.avatar--rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
.avatar--indigo { background: linear-gradient(135deg, #818cf8, #4f46e5); }
.card-name {
font-size: 32rpx; /* P1-9: 修正 16px → 30rpx */
line-height: 44rpx; /* 高度修正:添加 line-height */
font-weight: 600;
color: #242424;
flex-shrink: 0;
}
/* 最频繁:姓名+小字垂直排列 */
.card-name-group {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.card-name-sub {
display: flex;
align-items: center;
gap: 22rpx; /* H5 gap-3=12px → 12×2×0.875≈22rpx */
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
}
.card-header-spacer {
flex: 1;
}
/* 头部右侧指标区 */
.header-metrics {
display: flex;
align-items: center;
gap: 14rpx; /* P1-12: 修正 8px → 14rpx */
flex-shrink: 0;
}
/* 最频繁维度:右上角大字到店次数 */
.header-metrics--freq {
flex-direction: column;
align-items: flex-end;
gap: 0;
}
.freq-big-num {
display: flex;
align-items: baseline;
}
.freq-big-val {
font-size: 32rpx;
font-weight: 700;
color: #0052d9;
line-height: 1; /* 补充:紧凑行高 */
}
.freq-big-unit {
font-size: 22rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx */
font-weight: 400;
color: #a6a6a6;
margin-left: 2rpx;
}
.freq-big-label {
font-size: 18rpx; /* H5 text-[10px]=10px → 18rpx */
line-height: 33rpx; /* 补充:约 1.8 倍 */
color: #a6a6a6;
margin-top: -2rpx;
}
/* 最近到店维度:右上角大字 X天前到店 */
.header-metrics--recent {
gap: 4rpx;
}
.recent-big-num {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.recent-big-val {
font-size: 40rpx;
font-weight: 700;
color: #00a870;
line-height: 1; /* 补充:紧凑行高 */
}
.recent-big-unit {
font-size: 22rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx */
font-weight: 400;
color: #a6a6a6;
}
.metric-gray {
font-size: 22rpx;
line-height: 29rpx; /* 新增 */
color: #a6a6a6;
}
.metric-dark {
color: #393939;
font-weight: 600;
}
.metric-error {
color: #e34d59;
font-weight: 700;
}
/* 超期标签 */
.overdue-tag {
padding: 4rpx 12rpx; /* 补充修正6px → 12rpx */
font-size: 22rpx;
line-height: 29rpx; /* 补充text-xs line-height */
font-weight: 700;
border-radius: 8rpx;
}
.overdue-tag--danger {
background: rgba(227, 77, 89, 0.1);
color: #e34d59;
}
.overdue-tag--warn {
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
}
/* 消费潜力标签 */
.potential-tag {
padding: 4rpx 12rpx; /* 补充修正6px → 12rpx */
font-size: 22rpx;
line-height: 29rpx; /* 补充text-xs line-height */
border-radius: 8rpx;
}
.potential-tag--primary {
background: rgba(0, 82, 217, 0.1);
color: #0052d9;
}
.potential-tag--warning {
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
}
.potential-tag--success {
background: rgba(0, 168, 112, 0.1);
color: #00a870;
}
.potential-tag--gray {
background: #eeeeee;
color: #777777;
}
/* ===== 卡片中间行flex 布局,左对齐名字位置) ===== */
.card-mid-row {
display: flex;
align-items: center;
padding-left: 80rpx; /* 只保留左侧对齐 */
margin-bottom: 24rpx; /* 添加 mb-2 = 8px → 14rpx */
}
.mid-text {
font-size: 26rpx; /* P1-10: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #a6a6a6;
}
.mid-dark {
color: #393939;
font-weight: 600;
}
.mid-primary {
color: #0052d9;
font-weight: 500;
}
.mid-primary-bold {
color: #0052d9;
font-weight: 700;
}
.mid-ml {
margin-left: 30rpx; /* P0-5: 修正 16px → 30rpx */
}
.mid-ml-right {
margin-left: auto;
}
.mid-right {
margin-left: auto;
}
.mid-error {
color: #e34d59;
font-weight: 700;
}
/* ===== 网格布局 ===== */
.card-grid {
display: grid;
gap: 14rpx; /* 补充修正8px → 14rpx */
padding-left: 80rpx; /* 移除上下 padding只保留左侧对齐 */
}
.card-grid--3 {
grid-template-columns: repeat(3, 1fr);
text-align: center;
padding-bottom: 20rpx;
}
.card-grid--4 {
grid-template-columns: repeat(4, 1fr);
padding-bottom: 22rpx;
text-align: center;
}
.grid-cell {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.grid-label {
font-size: 18rpx;
color: #a6a6a6;
}
.grid-val {
font-size: 26rpx; /* 修正text-sm 14px → 26rpx */
line-height: 36rpx; /* text-sm line-height */
font-weight: 700;
color: #393939;
}
.grid-val--success {
color: #00a870;
}
.grid-val--warning {
color: #ed7b2f;
}
.grid-val--lg {
font-size: 30rpx; /* 修正text-base 16px → 30rpx */
line-height: 44rpx; /* 补充text-base line-height */
font-weight: 700;
color: #242424;
}
/* ===== 迷你柱状图(最频繁维度) ===== */
.mini-chart {
padding: 0rpx 0 20rpx 80rpx; /* H5 mb-2=8px → 8×2×0.875≈14rpx */
}
.mini-chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 4rpx;
}
.mini-chart-label {
font-size: 18rpx;
line-height: 33rpx; /* 新增 */
color: #c5c5c5;
}
.mini-chart-bars {
display: flex;
align-items: flex-end;
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
height: 42rpx; /* H5 h-6=24px → 42rpx */
}
.mini-bar-col {
flex: 1;
display: flex;
align-items: flex-end;
height: 100%;
}
.mini-bar {
width: 100%;
background: #0052d9;
border-radius: 8rpx 8rpx 0 0; /* 补充修正4px → 8rpx */
min-height: 4rpx;
}
.mini-chart-nums {
display: flex;
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
margin-top: 4rpx;
}
.mini-chart-num {
flex: 1;
text-align: center;
font-size: 16rpx; /* H5 text-[9px]=9px → 16rpx */
line-height: 29rpx; /* 新增 */
color: #c5c5c5;
}
.mini-chart-num--active {
color: #0052d9;
font-weight: 700;
}
/* ===== 助教行 ===== */
.card-assistant-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-left: 80rpx;
padding-top: 2rpx; /* 精确修正8px → 15rpx而非 14rpx*/
border-top: 1px solid #f3f3f3; /* P2-13: 发丝线用 px */
}
.assistant-label {
font-size: 22rpx;
line-height: 29rpx; /* 新增 */
color: #a6a6a6;
flex-shrink: 0;
}
.assistant-tag {
display: flex;
align-items: center;
gap: 4rpx;
}
.assistant-heart {
width: 24rpx;
height: 24rpx;
}
.assistant-name {
font-size: 22rpx;
line-height: 29rpx; /* 新增 */
font-weight: 500;
}
.assistant-name.assistant--assignee {
color: #e34d59;
font-weight: 700;
}
.assistant-name.assistant--abandoned {
color: #c5c5c5; /* H5 assistant-abandoned = gray-5 #c5c5c5 */
}
.assistant-name.assistant--normal {
color: #242424;
}
.assistant-sep {
font-size: 20rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx */
color: #c5c5c5;
margin: 0 12rpx; /* 补充修正6px → 12rpx */
}
/* 跟/弃 badge — 忠于 H5 原型:白字 + 渐变背景 + 阴影 */
.assistant-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28rpx;
height: 24rpx;
padding: 0 8rpx;
border-radius: 10rpx;
font-size: 18rpx;
line-height: 1;
font-weight: 700;
letter-spacing: 0.5rpx;
margin-left: 4rpx;
color: #ffffff;
transform: translateY(3rpx); /* H5 原型translateY(1.5px) → 3rpx */
}
.assistant-badge--follow {
background: linear-gradient(135deg, #e34d59 0%, #f26a76 100%);
border: 1px solid rgba(227, 77, 89, 0.28); /* 补充修正:发丝线用 px */
box-shadow: 0 2rpx 6rpx rgba(227, 77, 89, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.42); /* 补充修正:添加内阴影 */
}
.assistant-badge--drop {
background: linear-gradient(135deg, #d4d4d4 0%, #b6b6b6 100%);
border: 1px solid rgba(120, 120, 120, 0.18); /* 补充修正:发丝线用 px */
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.45); /* 补充修正:添加内阴影 */
}
/* ===== 最专一维度:助教服务明细表 ===== */
.loyal-table {
border-left: 6rpx solid #d6d6d6;
padding-left: 12rpx;
margin-top: 12rpx;
margin-bottom: 8rpx;
margin-left: 22rpx;
}
.loyal-row {
display: flex;
align-items: center;
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
padding: 0; /* 行间距由 space-y 控制 */
}
.loyal-row + .loyal-row {
margin-top: 2rpx; /* H5 space-y-2=8px → 14rpx */
}
.loyal-row--header {
padding-bottom: 10rpx; /* 间距由 margin-top 控制 */
}
.loyal-row--header .loyal-col {
font-size: 22rpx; /* H5 text-xs=12px → 22rpx */
line-height: 29rpx; /* 补充text-xs line-height */
color: #c5c5c5;
}
.loyal-col {
flex: 1;
text-align: right;
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
}
.loyal-col--name {
width: 168rpx;
flex: none;
text-align: left;
display: flex;
align-items: center;
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
}
.loyal-coach-name {
font-size: 24rpx; /* H5 text-sm=14px → 24rpx数据行继承 */
line-height: 36rpx; /* 新增 */
font-weight: 500;
}
.loyal-coach-name.assistant--assignee {
color: #e34d59;
font-weight: 700;
}
.loyal-coach-name.assistant--abandoned {
color: #a6a6a6;
}
.loyal-coach-name.assistant--normal {
color: #242424;
}
.loyal-val {
font-size: 26rpx; /* 补充:继承 text-sm */
line-height: 36rpx; /* 已有 */
font-weight: 500; /* H5 font-medium=500 */
color: #393939;
}
.loyal-val--primary {
font-weight: 700;
color: #0052d9;
}
.loyal-val--gray {
color: #8b8b8b;
}
/* 最专一头部右侧 */
.header-metrics--loyal {
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
}
.loyal-top-name {
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
font-weight: 600;
color: #e34d59;
}
.loyal-top-score {
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
font-weight: 700;
color: #0052d9;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "看板",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"metric-card": "/components/metric-card/metric-card",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty"
}
}

View File

@@ -0,0 +1,493 @@
// 财务看板页 — 忠于 H5 原型结构,内联 mock 数据
// TODO: 联调时替换 mock 数据为真实 API 调用
import { getRandomAiColor } from '../../utils/ai-color'
/** 目录板块定义 */
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',
/** AI 配色 */
aiColorClass: '',
/** 时间筛选 */
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: 'mahjong', text: '麻将房' },
{ value: 'teamBuilding', text: '团建房' },
],
/** 环比开关 */
compareEnabled: 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,
/** P1: 吸顶板块头H5: scaleX 从左滑入,同时筛选按钮 opacity 淡出) */
stickyHeaderVisible: false,
stickyHeaderEmoji: '',
stickyHeaderTitle: '',
stickyHeaderDesc: '',
/** 提示弹窗 */
tipVisible: false,
tipTitle: '',
tipContent: '',
/** 经营一览 */
overview: {
occurrence: '¥823,456',
occurrenceCompare: '12.5%',
discount: '-¥113,336',
discountCompare: '3.2%',
discountRate: '13.8%',
discountRateCompare: '1.5%',
confirmedRevenue: '¥710,120',
confirmedCompare: '8.7%',
cashIn: '¥698,500',
cashInCompare: '5.3%',
cashOut: '¥472,300',
cashOutCompare: '2.1%',
cashBalance: '¥226,200',
cashBalanceCompare: '15.2%',
balanceRate: '32.4%',
balanceRateCompare: '3.8%',
},
/** 预收资产 */
recharge: {
actualIncome: '¥352,800',
actualCompare: '18.5%',
firstCharge: '¥188,500',
firstChargeCompare: '12.3%',
renewCharge: '¥164,300',
renewChargeCompare: '8.7%',
consumed: '¥238,200',
consumedCompare: '5.2%',
cardBalance: '¥642,600',
cardBalanceCompare: '11.4%',
giftRows: [
{
label: '新增', total: '¥108,600', totalCompare: '9.8%',
wine: '¥43,200', wineCompare: '11.2%',
table: '¥54,100', tableCompare: '8.5%',
coupon: '¥11,300', couponCompare: '6.3%',
},
{
label: '消费', total: '¥75,800', totalCompare: '7.2%',
wine: '¥32,100', wineCompare: '8.1%',
table: '¥32,800', tableCompare: '6.5%',
coupon: '¥10,900', couponCompare: '5.8%',
},
{
label: '余额', total: '¥243,900', totalCompare: '4.5%',
wine: '¥118,500', wineCompare: '5.2%',
table: '¥109,200', tableCompare: '3.8%',
coupon: '¥16,200', couponCompare: '2.5%',
},
],
allCardBalance: '¥586,500',
allCardBalanceCompare: '6.2%',
},
/** 应计收入确认 */
revenue: {
structureRows: [
{ id: 'table', name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
{ id: 'area-a', name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
{ id: 'area-b', name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
{ id: 'area-c', name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
{ id: 'team', name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
{ id: 'mahjong', name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
{ id: 'coach-basic', name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
{ id: 'coach-incentive', name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
{ id: 'food', name: '食品酒水', amount: '¥119,556', discount: '-¥68,136', booked: '¥51,420', bookedCompare: '6.5%' },
],
priceItems: [
{ name: '开台消费', value: '¥358,600', compare: '9.2%' },
{ name: '酒水商品', value: '¥186,420', compare: '18.5%' },
{ name: '包厢费用', value: '¥165,636', compare: '12.1%' },
{ name: '助教服务', value: '¥112,800', compare: '15.3%' },
],
totalOccurrence: '¥823,456',
totalOccurrenceCompare: '12.5%',
discountItems: [
{ name: '团购优惠', value: '-¥56,200', compare: '5.2%' },
{ name: '手动调整 + 大客户优惠', value: '-¥34,800', compare: '3.1%' },
{ name: '赠送卡抵扣', desc: '台桌卡+酒水卡+抵用券', value: '-¥22,336', compare: '8.6%' },
{ name: '其他优惠', desc: '免单+抹零', value: '-¥0', compare: '' },
],
confirmedTotal: '¥710,120',
confirmedTotalCompare: '8.7%',
channelItems: [
{ name: '储值卡结算冲销', value: '¥238,200', compare: '11.2%' },
{ name: '现金/线上支付', value: '¥345,800', compare: '7.8%' },
{ name: '团购核销确认收入', desc: '团购成交价', value: '¥126,120', compare: '5.3%' },
],
},
/** 现金流入 */
cashflow: {
consumeItems: [
{ name: '纸币现金', desc: '柜台现金收款', value: '¥85,600', compare: '12.3%', isDown: true },
{ name: '线上收款', desc: '微信/支付宝/刷卡 已扣除平台服务费', value: '¥260,200', compare: '8.5%', isDown: false },
{ name: '团购平台', desc: '美团/抖音回款 已扣除平台服务费', value: '¥126,120', compare: '15.2%', isDown: false },
],
rechargeItems: [
{ name: '会员充值到账', desc: '首充/续费实收', value: '¥352,800', compare: '18.5%' },
],
total: '¥824,720',
totalCompare: '10.2%',
},
/** 现金流出 */
expense: {
operationItems: [
{ name: '食品饮料', value: '¥108,200', compare: '4.5%', isDown: false },
{ name: '耗材', value: '¥21,850', compare: '2.1%', isDown: true },
{ name: '报销', value: '¥10,920', compare: '6.8%', isDown: false },
],
fixedItems: [
{ name: '房租', value: '¥125,000', compare: '持平', isFlat: true },
{ name: '水电', value: '¥24,200', compare: '3.2%', isFlat: false },
{ name: '物业', value: '¥11,500', compare: '持平', isFlat: true },
{ name: '人员工资', value: '¥112,000', compare: '持平', isFlat: true },
],
coachItems: [
{ name: '基础课分成', value: '¥116,250', compare: '8.2%', isDown: false },
{ name: '激励课分成', value: '¥23,840', compare: '5.6%', isDown: false },
{ name: '充值提成', value: '¥12,640', compare: '12.3%', isDown: false },
{ name: '额外奖金', value: '¥11,500', compare: '3.1%', isDown: true },
],
platformItems: [
{ name: '汇来米', value: '¥10,680', compare: '1.5%' },
{ name: '美团', value: '¥11,240', compare: '2.8%' },
{ name: '抖音', value: '¥10,580', compare: '3.5%' },
],
total: '¥600,400',
totalCompare: '2.1%',
},
/** 助教分析 */
coachAnalysis: {
basic: {
totalPay: '¥232,500',
totalPayCompare: '15.3%',
totalShare: '¥116,250',
totalShareCompare: '15.3%',
avgHourly: '¥25/h',
avgHourlyCompare: '4.2%',
rows: [
{ level: '初级', pay: '¥68,600', payCompare: '12.5%', share: '¥34,300', shareCompare: '12.5%', hourly: '¥20/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '中级', pay: '¥82,400', payCompare: '18.2%', share: '¥41,200', shareCompare: '18.2%', hourly: '¥25/h', hourlyCompare: '8.7%' },
{ level: '高级', pay: '¥57,800', payCompare: '14.6%', share: '¥28,900', shareCompare: '14.6%', hourly: '¥30/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '星级', pay: '¥23,700', payCompare: '3.2%', payDown: true, share: '¥11,850', shareCompare: '3.2%', shareDown: true, hourly: '¥35/h', hourlyCompare: '持平', hourlyFlat: true },
],
},
incentive: {
totalPay: '¥112,800',
totalPayCompare: '8.2%',
totalShare: '¥33,840',
totalShareCompare: '8.2%',
avgHourly: '¥15/h',
avgHourlyCompare: '2.1%',
rows: [
{ level: '初级', pay: '¥32,400', payCompare: '6.8%', share: '¥9,720', shareCompare: '6.8%', hourly: '¥12/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '中级', pay: '¥38,600', payCompare: '10.5%', share: '¥11,580', shareCompare: '10.5%', hourly: '¥15/h', hourlyCompare: '5.2%' },
{ level: '高级', pay: '¥28,200', payCompare: '7.3%', share: '¥8,460', shareCompare: '7.3%', hourly: '¥18/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '星级', pay: '¥13,600', payCompare: '2.1%', payDown: true, share: '¥4,080', shareCompare: '2.1%', shareDown: true, hourly: '¥22/h', hourlyCompare: '持平', hourlyFlat: true },
],
},
},
},
onLoad() {
// P5: AI 配色
const aiColor = getRandomAiColor()
this.setData({ aiColorClass: aiColor.className })
},
onShow() {
// 同步 custom-tab-bar 选中态
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'board' })
// TODO: 联调时在此刷新看板数据
},
onReady() {
// P1: 缓存各 section 的 top 位置
this._cacheSectionPositions()
},
/** P1/P2: 页面滚动监听(节流 100ms— 匹配 H5 原型行为 */
/* CHANGE 2026-03-13 | intent: H5 原型下滑→显示吸顶头+隐藏筛选按钮,上滑→隐藏吸顶头+恢复筛选按钮;不再使用独立的 filterBarHidden 状态 */
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
// P1: 吸顶板块头 — 与 H5 updateStickyHeader 逻辑对齐
if (this._sectionTops.length === 0) return
// 偏移量tabs(~78rpx) + filter-bar(~70rpx) 约 148rpx ≈ 93px取 100 作为阈值
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
}
}
// H5: scrollY < 80 时隐藏吸顶头
if (scrollTop < 80) {
if (this.data.stickyHeaderVisible) {
this.setData({ stickyHeaderVisible: false })
}
return
}
const toc = this.data.tocItems[currentIdx]
if (isScrollingDown && !this.data.stickyHeaderVisible) {
// H5: 下滑且吸顶头未显示 → 显示吸顶头(筛选按钮通过 CSS opacity 自动淡出)
this.setData({
stickyHeaderVisible: true,
stickyHeaderEmoji: toc?.emoji || '',
stickyHeaderTitle: toc?.title || '',
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
currentSectionIndex: currentIdx,
})
} else if (!isScrollingDown && this.data.stickyHeaderVisible) {
// H5: 上滑且吸顶头显示 → 隐藏吸顶头(筛选按钮通过 CSS opacity 自动恢复)
this.setData({ stickyHeaderVisible: false })
} else if (this.data.stickyHeaderVisible && currentIdx !== this.data.currentSectionIndex) {
// H5: 吸顶头显示时板块切换 → 更新内容
this.setData({
stickyHeaderEmoji: toc?.emoji || '',
stickyHeaderTitle: toc?.title || '',
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
currentSectionIndex: currentIdx,
})
}
},
/** 缓存 section 位置(私有) */
_sectionTops: [] as number[],
_lastScrollTop: 0,
_lastScrollTime: 0,
/** H5 原型吸顶头包含板块描述,从 data-section-desc 映射 */
_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))
})
},
onPullDownRefresh() {
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 看板 Tab 切换 */
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'customer') {
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
} else if (tab === 'coach') {
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
}
},
/** 时间筛选变更 */
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 || '本月',
})
},
/** 区域筛选变更 */
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 || '全部区域',
})
// P3: 区域变更后重新缓存 section 位置(预收资产可能隐藏/显示)
setTimeout(() => this._cacheSectionPositions(), 300)
},
/** 环比开关切换 */
toggleCompare() {
this.setData({ compareEnabled: !this.data.compareEnabled })
},
/** 目录导航开关 */
toggleToc() {
this.setData({ tocVisible: !this.data.tocVisible })
},
closeToc() {
this.setData({ tocVisible: false })
},
/** 目录项点击 → 滚动到对应板块P0: 使用 pageScrollTo 替代 scrollIntoView */
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 })
},
/** P4: 错误态重试 */
onRetry() {
this.setData({ pageState: 'normal' })
},
})

View File

@@ -0,0 +1,782 @@
<!-- 财务看板页 — 忠于 H5 原型结构 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无财务数据" />
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">
<text class="retry-btn-text">点击重试</text>
</view>
</view>
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab 导航 -->
<view class="board-tabs">
<view class="board-tab board-tab--active" data-tab="finance">
<text>财务</text>
</view>
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
<text>客户</text>
</view>
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
<text>助教</text>
</view>
</view>
<!-- 筛选区域 -->
<!-- CHANGE 2026-03-13 | intent: H5 原型筛选按钮与吸顶板块头共存于同一容器,通过 opacity/scaleX 动画切换;目录按钮始终可见,只有筛选按钮和环比开关淡出 -->
<view class="filter-bar">
<view class="filter-bar-inner">
<!-- 目录按钮(始终可见,不受吸顶头影响) -->
<view class="toc-btn" bindtap="toggleToc">
<t-icon name="view-list" size="40rpx" color="#ffffff" />
</view>
<!-- 时间筛选(吸顶头显示时淡出) -->
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
<filter-dropdown
label="本月"
options="{{timeOptions}}"
value="{{selectedTime}}"
bind:change="onTimeChange"
/>
</view>
<!-- 区域筛选(吸顶头显示时淡出) -->
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
<filter-dropdown
label="全部区域"
options="{{areaOptions}}"
value="{{selectedArea}}"
bind:change="onAreaChange"
/>
</view>
<!-- 环比开关(始终可见,不受吸顶头影响) -->
<view class="compare-switch" bindtap="toggleCompare">
<text class="compare-label">环比</text>
<view class="compare-toggle {{compareEnabled ? 'compare-toggle--active' : ''}}">
<view class="compare-toggle-dot"></view>
</view>
</view>
</view>
<!-- 吸顶板块头(滚动到非首屏时从左滑入,覆盖在筛选按钮上方) -->
<view class="sticky-section-header {{stickyHeaderVisible ? 'sticky-section-header--show' : ''}}">
<text class="sticky-header-emoji">{{stickyHeaderEmoji}}</text>
<view class="sticky-header-content">
<text class="sticky-header-title">{{stickyHeaderTitle}}</text>
<text class="sticky-header-desc">{{stickyHeaderDesc}}</text>
</view>
<view class="sticky-header-tags">
<text class="sticky-header-tag" wx:if="{{selectedTimeText !== '本月'}}">{{selectedTimeText}}</text>
<text class="sticky-header-tag" wx:if="{{selectedAreaText !== '全部区域'}}">{{selectedAreaText}}</text>
</view>
</view>
</view>
<!-- 内容区(页面自然滚动) -->
<view class="board-content">
<!-- ===== 板块 1: 经营一览(深色) ===== -->
<view id="section-overview" class="card-section section-dark">
<view class="card-header-dark">
<text class="card-header-emoji">📈</text>
<view class="card-header-text">
<text class="card-header-title-dark">经营一览</text>
<text class="card-header-desc-dark">快速了解收入与现金流的整体健康度</text>
</view>
</view>
<!-- 收入概览 -->
<view class="sub-section-label">
<text class="sub-label-text">收入概览</text>
<text class="sub-label-desc">记账口径收入与优惠</text>
</view>
<view class="overview-grid-3">
<view class="overview-cell">
<view class="cell-label-row">
<text class="cell-label-light">发生额/正价</text>
<view class="help-icon-light" data-key="occurrence" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white">{{overview.occurrence}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.occurrenceCompare}}</text>
</view>
</view>
<view class="overview-cell">
<view class="cell-label-row">
<text class="cell-label-light">总优惠</text>
<view class="help-icon-light" data-key="discount" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-red">{{overview.discount}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-down">↓{{overview.discountCompare}}</text>
</view>
</view>
<view class="overview-cell">
<view class="cell-label-row">
<text class="cell-label-light">优惠占比</text>
</view>
<text class="cell-value-gray">{{overview.discountRate}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-down">↓{{overview.discountRateCompare}}</text>
</view>
</view>
</view>
<!-- 成交/确认收入 -->
<view class="confirmed-row">
<view class="confirmed-left">
<text class="confirmed-label">成交/确认收入</text>
<view class="help-icon-light" data-key="confirmed" bindtap="onHelpTap">?</view>
</view>
<view class="confirmed-right">
<text class="confirmed-value">{{overview.confirmedRevenue}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.confirmedCompare}}</text>
</view>
</view>
</view>
<view class="section-divider-light"></view>
<!-- 现金流水概览 -->
<view class="sub-section-label">
<text class="sub-label-text">现金流水概览</text>
<text class="sub-label-desc">往期为已结算 本期为截至当前的发生额</text>
</view>
<view class="overview-grid-2">
<view class="overview-cell-bg">
<view class="cell-label-row">
<text class="cell-label-light">实收/现金流入</text>
<view class="help-icon-light" data-key="cashIn" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white-sm">{{overview.cashIn}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.cashInCompare}}</text>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<text class="cell-label-light">现金支出</text>
<view class="help-icon-light" data-key="cashOut" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-gray-sm">{{overview.cashOut}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.cashOutCompare}}</text>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<text class="cell-label-light">现金结余</text>
<view class="help-icon-light" data-key="balance" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white-sm">{{overview.cashBalance}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.cashBalanceCompare}}</text>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<text class="cell-label-light">结余率</text>
</view>
<text class="cell-value-white-sm">{{overview.balanceRate}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.balanceRateCompare}}</text>
</view>
</view>
</view>
<!-- AI 洞察 -->
<!-- CHANGE 2026-03-12 | intent: H5 原型使用 SVG 机器人图标,不可用 emoji 替代;规范要求内联 SVG 导出为文件用 image 引用 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" class="ai-insight-icon-img" />
</view>
<text class="ai-insight-title">AI 智能洞察</text>
</view>
<view class="ai-insight-body">
<text class="ai-insight-line"><text class="ai-insight-dim">优惠率Top</text>团购(5.2%) / 大客户(3.1%) / 赠送卡(2.5%)</text>
<text class="ai-insight-line"><text class="ai-insight-dim">差异最大:</text>酒水(+18%) / 台桌(-5%) / 包厢(+12%)</text>
<!-- CHANGE 2026-03-12 | intent: H5 原型第三行"充值高但消耗低"有 underline 样式 -->
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text><text class="ai-insight-underline">充值高但消耗低</text>,会员活跃度需提升</text>
</view>
</view>
</view>
<!-- ===== 板块 2: 预收资产(仅"全部区域"时显示) ===== -->
<view id="section-recharge" class="card-section" wx:if="{{selectedArea === 'all'}}">
<view class="card-header-light">
<text class="card-header-emoji">💳</text>
<view class="card-header-text">
<text class="card-header-title-light">预收资产</text>
<text class="card-header-desc-light">会员卡充值与余额 掌握资金沉淀 辅助会员运营策略定制</text>
</view>
</view>
<!-- 储值卡统计 -->
<view class="section-body">
<!-- 全类别会员卡余额合计 -->
<!-- CHANGE 2026-03-13 | intent: H5 中 label 和 help-icon 在同一行flex items-center mb-1MP 需要用 view 包裹实现同行布局 -->
<view class="total-balance-row">
<view class="total-balance-left">
<view class="total-balance-label">
<text>全类别会员卡余额合计</text>
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</view>
</view>
<text class="total-balance-note">仅经营参考,非财务属性</text>
</view>
<view class="total-balance-right">
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.allCardBalanceCompare}}</text>
</view>
</view>
</view>
<text class="card-section-title">储值卡统计</text>
<view class="table-bordered">
<!-- 行1储值卡充值实收 -->
<view class="table-row table-row--highlight">
<view class="table-row-left">
<text class="table-row-label-bold">储值卡充值实收</text>
<view class="help-icon-dark" data-key="rechargeActual" bindtap="onHelpTap">?</view>
</view>
<view class="table-row-right">
<text class="table-row-value-lg">{{recharge.actualIncome}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.actualCompare}}</text>
</view>
</view>
</view>
<!-- 行2首充/续费/消耗 三列 -->
<view class="table-row-grid3">
<view class="grid-cell">
<view class="cell-label-row-sm">
<text class="cell-label-sm">首充</text>
<view class="help-icon-dark-sm" data-key="firstCharge" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.firstCharge}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.firstChargeCompare}}</text>
</view>
</view>
<view class="grid-cell">
<view class="cell-label-row-sm">
<text class="cell-label-sm">续费</text>
<view class="help-icon-dark-sm" data-key="renewCharge" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.renewCharge}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.renewChargeCompare}}</text>
</view>
</view>
<view class="grid-cell">
<view class="cell-label-row-sm">
<text class="cell-label-sm">消耗</text>
<view class="help-icon-dark-sm" data-key="consume" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.consumed}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.consumedCompare}}</text>
</view>
</view>
</view>
<!-- 行3储值卡总余额 -->
<view class="table-row table-row--footer">
<view class="table-row-left">
<text class="table-row-label-bold">储值卡总余额</text>
<view class="help-icon-dark" data-key="cardBalance" bindtap="onHelpTap">?</view>
</view>
<view class="table-row-right">
<text class="table-row-value-lg">{{recharge.cardBalance}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.cardBalanceCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 赠送卡统计详情 -->
<text class="card-section-title" style="margin-top: 35rpx;">赠送卡统计详情</text>
<view class="table-bordered">
<!-- 表头 -->
<view class="gift-table-header">
<text class="gift-col gift-col--name">类型</text>
<text class="gift-col">酒水卡</text>
<text class="gift-col">台费卡</text>
<text class="gift-col">抵用券</text>
</view>
<!-- 数据行(新增/消费/余额) -->
<view class="gift-table-row {{compareEnabled ? 'gift-table-row--compare' : ''}}" wx:for="{{recharge.giftRows}}" wx:key="label">
<!-- 左列:标题 + 环比 / 总金额 -->
<view class="gift-col gift-col--name">
<view class="gift-label-line">
<text class="gift-row-label">{{item.label}}</text>
<text class="compare-text-up-xs" wx:if="{{compareEnabled}}">↑{{item.totalCompare}}</text>
</view>
<text class="gift-row-total">{{item.total}}</text>
</view>
<!-- 酒水卡 -->
<view class="gift-col">
<text class="gift-col-val">{{item.wine}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.wineCompare}}</text>
</view>
</view>
<!-- 台费卡 -->
<view class="gift-col">
<text class="gift-col-val">{{item.table}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.tableCompare}}</text>
</view>
</view>
<!-- 抵用券 -->
<view class="gift-col">
<text class="gift-col-val">{{item.coupon}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.couponCompare}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 3: 应计收入确认 ===== -->
<view id="section-revenue" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">💰</text>
<view class="card-header-text">
<text class="card-header-title-light">【记账】应计收入确认</text>
<text class="card-header-desc-light">从发生额到入账收入的全流程</text>
</view>
</view>
<view class="section-body">
<!-- 收入结构 -->
<view class="sub-title-row">
<text class="sub-title-text">收入结构</text>
<text class="sub-title-desc">按业务查看各项应计收入的构成</text>
</view>
<view class="table-bordered">
<!-- 表头 -->
<view class="rev-table-header">
<text class="rev-col rev-col--name">项目</text>
<text class="rev-col">发生额</text>
<text class="rev-col">优惠</text>
<text class="rev-col">入账</text>
</view>
<!-- 数据行 -->
<block wx:for="{{revenue.structureRows}}" wx:key="id">
<view class="rev-table-row {{item.isSub ? 'rev-table-row--sub' : ''}}">
<view class="rev-col rev-col--name">
<text class="{{item.isSub ? 'rev-name-sub' : 'rev-name'}}">{{item.name}}</text>
<text class="rev-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<text class="rev-col rev-val">{{item.amount}}</text>
<text class="rev-col rev-val {{item.discount !== '-' ? 'rev-val--red' : 'rev-val--muted'}}">{{item.discount}}</text>
<view class="rev-col">
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{item.booked}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled && item.bookedCompare}}">
<text class="compare-text-up-xs">↑{{item.bookedCompare}}</text>
</view>
</view>
</view>
</block>
</view>
<!-- 收入确认(损益链) -->
<!-- CHANGE 2026-03-13 | intent: H5 收入结构外层 mb-5=20px→36rpx(87.5%取偶),损益链与收入结构之间的间距 -->
<view class="sub-title-row" style="margin-top: 36rpx;">
<text class="sub-title-text">收入确认</text>
<text class="sub-title-desc">从正价到收款方式的损益链</text>
</view>
<view class="table-bordered">
<!-- 项目正价 标题 -->
<view class="flow-header">
<text class="flow-header-title">项目正价</text>
<text class="flow-header-desc">即标价测算</text>
</view>
<!-- 正价明细(左侧竖线) -->
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.priceItems}}" wx:key="name">
<text class="flow-detail-name">{{item.name}}</text>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 发生额合计 -->
<view class="flow-total-row">
<view class="flow-total-left">
<text class="flow-total-label">发生额</text>
<text class="flow-total-desc">即上列正价合计</text>
</view>
<view class="flow-total-right">
<text class="flow-total-value">{{revenue.totalOccurrence}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{revenue.totalOccurrenceCompare}}</text>
</view>
</view>
</view>
<!-- 优惠扣减 -->
<view class="flow-header flow-header--deduct">
<text class="flow-header-title">优惠扣减</text>
</view>
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.discountItems}}" wx:key="name">
<view class="flow-detail-name-group">
<text class="flow-detail-name">{{item.name}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<view class="flow-detail-right">
<text class="flow-detail-val {{item.value === '-¥0' ? 'flow-detail-val--muted' : 'flow-detail-val--red'}}">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled && item.compare}}">
<text class="compare-text-down-xs">↓{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 成交收入 -->
<view class="flow-total-row flow-total-row--accent">
<view class="flow-total-left">
<text class="flow-total-label">成交/确认收入</text>
<text class="flow-total-desc">即发生额扣除以上优惠抵扣后的金额</text>
<text class="flow-total-desc">此金额收款渠道分布如下</text>
</view>
<view class="flow-total-right">
<text class="flow-total-value">{{revenue.confirmedTotal}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{revenue.confirmedTotalCompare}}</text>
</view>
</view>
</view>
<!-- 收款渠道 -->
<view class="flow-header">
<text class="flow-header-title">收款渠道明细</text>
</view>
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.channelItems}}" wx:key="name">
<view class="flow-detail-name-group">
<text class="flow-detail-name">{{item.name}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 4: 现金流入 ===== -->
<view id="section-cashflow" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">🧾</text>
<view class="card-header-text">
<text class="card-header-title-light">【现金流水】流入</text>
<text class="card-header-desc-light">实际到账的资金来源明细</text>
</view>
</view>
<view class="section-body">
<!-- 消费收入 -->
<text class="flow-group-label">消费收入</text>
<view class="flow-item-list">
<view class="flow-item" wx:for="{{cashflow.consumeItems}}" wx:key="name">
<view class="flow-item-left">
<text class="flow-item-name">{{item.name}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 充值收入 -->
<!-- CHANGE 2026-03-13 | intent: H5 mt-3=12px→22rpx(87.5%取偶) -->
<text class="flow-group-label" style="margin-top: 22rpx;">充值收入</text>
<view class="flow-item-list">
<view class="flow-item" wx:for="{{cashflow.rechargeItems}}" wx:key="name">
<view class="flow-item-left">
<text class="flow-item-name">{{item.name}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 合计 -->
<view class="flow-sum-row">
<text class="flow-sum-label">现金流入合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{cashflow.total}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{cashflow.totalCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 5: 现金流出 ===== -->
<view id="section-expense" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">📤</text>
<view class="card-header-text">
<text class="card-header-title-light">【现金流水】流出</text>
<text class="card-header-desc-light">清晰呈现各类开销与结构</text>
</view>
</view>
<view class="section-body">
<!-- 进货与运营 3列 -->
<text class="expense-group-label">进货与运营</text>
<view class="expense-grid-3">
<view class="expense-cell" wx:for="{{expense.operationItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 固定支出 2×2 -->
<text class="expense-group-label">固定支出</text>
<view class="expense-grid-2">
<view class="expense-cell" wx:for="{{expense.fixedItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.isFlat ? '' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 助教薪资 2×2 -->
<text class="expense-group-label">助教薪资</text>
<view class="expense-grid-2">
<view class="expense-cell" wx:for="{{expense.coachItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 平台服务费 3列 -->
<text class="expense-group-label">平台服务费</text>
<text class="expense-group-note">服务费在流水流入时,平台已经扣除。不产生支出流水。</text>
<view class="expense-grid-3">
<view class="expense-cell" wx:for="{{expense.platformItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
<!-- 支出合计 -->
<view class="flow-sum-row">
<text class="flow-sum-label">支出合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{expense.total}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{expense.totalCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 6: 助教分析 ===== -->
<view id="section-coach" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">🎱</text>
<view class="card-header-text">
<text class="card-header-title-light">助教分析</text>
<text class="card-header-desc-light">全部助教服务收入与分成的平均值,用以评估球房分成效益</text>
</view>
</view>
<view class="section-body">
<!-- 基础课 -->
<text class="card-section-title">助教 <text class="card-section-title-sub">(基础课)</text></text>
<view class="table-bordered">
<view class="coach-fin-header">
<text class="coach-fin-col coach-fin-col--name">级别</text>
<text class="coach-fin-col">客户支付</text>
<text class="coach-fin-col">球房抽成</text>
<text class="coach-fin-col">小时平均</text>
</view>
<!-- 合计行 -->
<view class="coach-fin-row coach-fin-row--total">
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalPay}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalPayCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalShare}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalShareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.basic.avgHourly}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.avgHourlyCompare}}</text>
</view>
</view>
</view>
<!-- 明细行 -->
<view class="coach-fin-row coach-fin-row--detail" wx:for="{{coachAnalysis.basic.rows}}" wx:key="level">
<text class="coach-fin-col coach-fin-col--name">{{item.level}}</text>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.pay}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.payDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.payDown ? '↓' : '↑'}}{{item.payCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.share}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.shareDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.shareDown ? '↓' : '↑'}}{{item.shareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{item.hourly}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.hourlyFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.hourlyFlat ? '' : '↑'}}{{item.hourlyCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 激励课 -->
<text class="card-section-title" style="margin-top: 28rpx;">助教 <text class="card-section-title-sub">(激励课)</text></text>
<view class="table-bordered">
<view class="coach-fin-header">
<text class="coach-fin-col coach-fin-col--name">级别</text>
<text class="coach-fin-col">客户支付</text>
<text class="coach-fin-col">球房抽成</text>
<text class="coach-fin-col">小时平均</text>
</view>
<view class="coach-fin-row coach-fin-row--incentive-total">
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalPay}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalPayCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalShare}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalShareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.incentive.avgHourly}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.avgHourlyCompare}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</view>
<!-- ===== 目录导航遮罩 ===== -->
<view class="toc-overlay" wx:if="{{tocVisible}}" catchtap="closeToc"></view>
<!-- ===== 目录导航面板 ===== -->
<view class="toc-panel {{tocVisible ? 'toc-panel--show' : ''}}">
<view class="toc-header">
<text class="toc-header-text">📊 财务看板导航</text>
</view>
<view class="toc-list">
<view
class="toc-item {{currentSectionIndex === index ? 'toc-item--active' : ''}}"
wx:for="{{tocItems}}"
wx:key="sectionId"
data-index="{{index}}"
bindtap="onTocItemTap"
>
<text class="toc-item-emoji">{{item.emoji}}</text>
<text class="toc-item-text">{{item.title}}</text>
</view>
</view>
</view>
<!-- ===== 指标说明弹窗 ===== -->
<view class="tip-overlay" wx:if="{{tipVisible}}" catchtap="closeTip"></view>
<view class="tip-toast {{tipVisible ? 'tip-toast--show' : ''}}">
<view class="tip-toast-header">
<text class="tip-toast-title">{{tipTitle}}</text>
<view class="tip-toast-close" bindtap="closeTip">
<t-icon name="close" size="36rpx" color="#8b8b8b" />
</view>
</view>
<text class="tip-toast-content">{{tipContent}}</text>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" />
<dev-fab wx:if="{{false}}" />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "对话历史",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,89 @@
import { mockChatHistory } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time'
/** VI 规范 §6.2AI 图标配色系统6种 */
const ICON_GRADIENTS = [
'linear-gradient(135deg, #667eea 0%, #4a5fc7 100%)', // indigo
'linear-gradient(135deg, #764ba2 0%, #5b3080 100%)', // purple
'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', // red
'linear-gradient(135deg, #e67e22 0%, #ca6c17 100%)', // orange
'linear-gradient(135deg, #d4a017 0%, #b8860b 100%)', // yellow
'linear-gradient(135deg, #2980b9 0%, #1a5276 100%)', // blue
]
/** 带展示标签的对话历史项 */
interface ChatHistoryDisplay {
id: string
title: string
lastMessage: string
timestamp: string
customerName?: string
/** 格式化后的时间标签 */
timeLabel: string
/** 图标背景渐变VI §6.2 AI 图标配色,每条随机) */
iconGradient: string
}
Page({
data: {
/** 页面状态loading / empty / normal / error */
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
/** 状态栏高度 */
statusBarHeight: 0,
/** 对话历史列表 */
list: [] as ChatHistoryDisplay[],
},
onLoad() {
const sysInfo = wx.getWindowInfo()
this.setData({ statusBarHeight: sysInfo.statusBarHeight || 44 })
this.loadData()
},
/** 加载数据 */
loadData() {
this.setData({ pageState: 'loading' })
try {
setTimeout(() => {
// TODO: 替换为真实 API 调用
const sorted = sortByTimestamp(mockChatHistory)
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
...item,
timeLabel: formatRelativeTime(item.timestamp),
iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)],
}))
this.setData({
list,
pageState: list.length === 0 ? 'empty' : 'normal',
})
}, 400)
} catch {
this.setData({ pageState: 'error' })
}
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
/** 重试加载 */
onRetry() {
this.loadData()
},
/** 点击对话记录 → 跳转 chat 页面 */
onItemTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: '/pages/chat/chat?historyId=' + id })
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 600)
},
})

View File

@@ -0,0 +1,68 @@
<!-- pages/chat-history/chat-history.wxml — 对话历史 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-icon">😵</text>
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text class="retry-btn-text">重新加载</text>
</view>
</view>
</view>
<!-- 空态 -->
<view class="page-empty-wrap" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty">
<t-empty description="暂无对话记录" />
</view>
</view>
<!-- 正常态 -->
<view class="page-normal" wx:elif="{{pageState === 'normal'}}">
<!-- 对话列表 -->
<view class="chat-list">
<view
class="chat-item"
hover-class="chat-item--hover"
wx:for="{{list}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onItemTap"
>
<view class="chat-icon-box" style="background: {{item.iconGradient}};">
<t-icon name="chat" size="40rpx" color="#ffffff" />
</view>
<view class="chat-content">
<view class="chat-top">
<text class="chat-title text-ellipsis">{{item.title}}</text>
<text class="chat-time">{{item.timeLabel}}</text>
</view>
<view class="chat-bottom">
<text class="chat-summary text-ellipsis" wx:if="{{item.customerName}}">{{item.customerName}} · {{item.lastMessage}}</text>
<text class="chat-summary text-ellipsis" wx:else>{{item.lastMessage}}</text>
</view>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
</view>
<!-- 底部提示 -->
<view class="list-footer">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button />
</view>
<dev-fab />

View File

@@ -0,0 +1,177 @@
/* pages/chat-history/chat-history.wxss — 对话历史页样式 */
/* ========== 自定义导航栏 ========== */
.safe-area-top {
background-color: #ffffff;
}
.custom-nav {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
position: relative;
}
.nav-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.nav-back--hover {
background-color: var(--color-gray-2);
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-lg);
font-weight: 500;
color: var(--color-gray-13);
}
/* ========== 加载态 & 空态 ========== */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
/* ========== 错误态 ========== */
.page-error {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--color-gray-1);
}
.error-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
}
.error-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.error-text {
font-size: var(--font-base);
color: var(--color-gray-8);
margin-bottom: 32rpx;
}
.retry-btn {
padding: 16rpx 48rpx;
background-color: var(--color-primary);
border-radius: var(--radius-lg);
}
.retry-btn--hover {
opacity: 0.8;
}
.retry-btn-text {
font-size: var(--font-sm);
color: #ffffff;
}
/* ========== 容器 ========== */
.page-empty-wrap,
.page-normal {
min-height: 100vh;
background-color: #ffffff;
}
/* ========== 对话列表 ========== */
.chat-list {
background: #ffffff;
}
.chat-item {
display: flex;
align-items: center;
padding: 32rpx;
gap: 24rpx;
border-bottom: 2rpx solid var(--color-gray-1, #f3f3f3);
}
.chat-item--hover {
background-color: var(--color-gray-1, #f3f3f3);
}
/* 图标容器 */
.chat-icon-box {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.2);
}
/* 内容区 */
.chat-content {
flex: 1;
min-width: 0;
}
.chat-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.chat-title {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-gray-13, #242424);
flex: 1;
min-width: 0;
}
.chat-time {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
flex-shrink: 0;
margin-left: 16rpx;
}
.chat-bottom {
display: flex;
align-items: center;
}
.chat-summary {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
max-width: 100%;
}
/* ========== 底部提示 ========== */
.list-footer {
text-align: center;
padding: 32rpx 0 64rpx;
}
.footer-text {
font-size: 20rpx;
color: var(--color-gray-5, #c5c5c5);
}

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "AI 助手",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,221 @@
// pages/chat/chat.ts — AI 对话页
import { mockChatMessages } from '../../utils/mock-data'
import type { ChatMessage } from '../../utils/mock-data'
import { simulateStreamOutput } from '../../utils/chat'
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
if (!data) return []
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
}
/** 为消息列表补充展示字段时间分割线、IM时间、引用卡片*/
function enrichMessages(msgs: ChatMessage[]) {
return msgs.map((m, i) => ({
...m,
timeLabel: formatRelativeTime(m.timestamp),
imTimeLabel: formatIMTime(m.timestamp),
showTimeDivider: shouldShowTimeDivider(
i === 0 ? null : msgs[i - 1].timestamp,
m.timestamp,
),
referenceCard: m.referenceCard
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
: undefined,
}))
}
/** Mock AI 回复模板 */
const mockAIReplies = [
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
]
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
/** 状态栏高度 */
statusBarHeight: 0,
/** 消息列表 */
messages: [] as Array<ChatMessage & {
timeLabel?: string
imTimeLabel?: string
showTimeDivider?: boolean
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
}>,
/** 输入框内容 */
inputText: '',
/** AI 正在流式回复 */
isStreaming: false,
/** 流式输出中的内容 */
streamingContent: '',
/** 滚动锚点 */
scrollToId: '',
/** 页面顶部引用卡片(从其他页面跳转时) */
referenceCard: null as { title: string; summary: string } | null,
/** 客户 ID */
customerId: '',
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
inputBarBottom: 0,
},
/** 消息计数器,用于生成唯一 ID */
_msgCounter: 0,
onLoad(options) {
const sysInfo = wx.getWindowInfo()
const customerId = options?.customerId || ''
this.setData({
customerId,
statusBarHeight: sysInfo.statusBarHeight || 44,
})
this.loadMessages(customerId)
},
/** 加载消息Mock */
loadMessages(customerId: string) {
this.setData({ pageState: 'loading' })
try {
setTimeout(() => {
// TODO: 替换为真实 API 调用
const messages = enrichMessages(mockChatMessages)
this._msgCounter = messages.length
// 如果携带 customerId显示引用卡片
const referenceCard = customerId
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` }
: null
const isEmpty = messages.length === 0 && !referenceCard
this.setData({
pageState: isEmpty ? 'empty' : 'normal',
messages,
referenceCard,
})
this.scrollToBottom()
}, 500)
} catch {
this.setData({ pageState: 'error' })
}
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
/** 重试加载 */
onRetry() {
this.loadMessages(this.data.customerId)
},
/** 软键盘弹出:输入栏上移至键盘顶部 */
onInputFocus(e: WechatMiniprogram.InputFocus) {
const keyboardHeight = e.detail.height || 0
this.setData({ inputBarBottom: keyboardHeight })
setTimeout(() => this.scrollToBottom(), 120)
},
/** 软键盘收起:输入栏归位 */
onInputBlur() {
this.setData({ inputBarBottom: 0 })
},
/** 输入框内容变化 */
onInputChange(e: WechatMiniprogram.Input) {
this.setData({ inputText: e.detail.value })
},
/** 发送消息 */
onSendMessage() {
const text = this.data.inputText.trim()
if (!text || this.data.isStreaming) return
this._msgCounter++
const now = new Date().toISOString()
const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const userMsg = {
id: `msg-user-${this._msgCounter}`,
role: 'user' as const,
content: text,
timestamp: now,
timeLabel: '刚刚',
imTimeLabel: formatIMTime(now),
showTimeDivider: shouldShowTimeDivider(prevTs, now),
}
const messages = [...this.data.messages, userMsg]
this.setData({
messages,
inputText: '',
pageState: 'normal',
})
this.scrollToBottom()
setTimeout(() => {
this.triggerAIReply()
}, 300)
},
/** 触发 AI 流式回复 */
triggerAIReply() {
this._msgCounter++
const aiMsgId = `msg-ai-${this._msgCounter}`
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
const aiNow = new Date().toISOString()
const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const aiMsg = {
id: aiMsgId,
role: 'assistant' as const,
content: '',
timestamp: aiNow,
timeLabel: '刚刚',
imTimeLabel: formatIMTime(aiNow),
showTimeDivider: shouldShowTimeDivider(prevTs, aiNow),
}
const messages = [...this.data.messages, aiMsg]
this.setData({
messages,
isStreaming: true,
streamingContent: '',
})
this.scrollToBottom()
const aiIndex = messages.length - 1
simulateStreamOutput(replyText, (partial: string) => {
const key = `messages[${aiIndex}].content`
this.setData({
[key]: partial,
streamingContent: partial,
})
this.scrollToBottom()
}).then(() => {
this.setData({
isStreaming: false,
streamingContent: '',
})
})
},
/** 滚动到底部 */
scrollToBottom() {
setTimeout(() => {
this.setData({ scrollToId: '' })
setTimeout(() => {
this.setData({ scrollToId: 'scroll-bottom' })
}, 50)
}, 50)
},
})

View File

@@ -0,0 +1,165 @@
<!-- pages/chat/chat.wxml — AI 对话页 -->
<wxs src="../../utils/time.wxs" module="timefmt" />
<!-- 加载态 -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text class="retry-btn-text">重新加载</text>
</view>
</view>
</view>
<!-- 正常态 -->
<view class="chat-page" wx:elif="{{pageState === 'normal' || pageState === 'empty'}}">
<!-- 消息列表 -->
<scroll-view
class="message-list"
scroll-y
scroll-into-view="{{scrollToId}}"
scroll-with-animation
enhanced
show-scrollbar="{{false}}"
style="bottom: {{inputBarBottom}}px;"
>
<!-- 引用内容卡片(从其他页面跳转时显示)-->
<view class="reference-card" wx:if="{{referenceCard}}">
<view class="reference-label-row">
<text class="reference-tag">引用内容</text>
<text class="reference-source">{{referenceCard.title}}</text>
</view>
<text class="reference-summary">{{referenceCard.summary}}</text>
</view>
<!-- 空对话提示 -->
<view class="empty-hint" wx:if="{{pageState === 'empty' && messages.length === 0}}">
<view class="empty-ai-avatar">
<image src="/assets/icons/ai-robot.svg" class="empty-ai-img" mode="aspectFit" />
</view>
<text class="empty-text">你好,我是 AI 助手</text>
<text class="empty-sub">有什么可以帮你的?</text>
</view>
<!-- 消息气泡列表 -->
<block wx:for="{{messages}}" wx:key="id">
<!--
IM 时间分割线
· 首条消息始终显示
· 相邻消息间隔 ≥ 5 分钟时显示
· 格式:今天 HH:mm / 今年 MM-DD HH:mm / 跨年 YYYY-MM-DD HH:mm
-->
<view class="time-divider" wx:if="{{item.showTimeDivider}}">
<view class="time-divider-inner">
<text class="time-divider-text">{{timefmt.imTime(item.timestamp)}}</text>
</view>
</view>
<!-- 用户消息:右对齐蓝色 -->
<view class="message-row message-user" wx:if="{{item.role === 'user'}}" id="msg-{{item.id}}">
<view class="user-bubble-col">
<view class="bubble bubble-user">
<text class="bubble-text">{{item.content}}</text>
</view>
<!-- 用户侧引用卡片(用户发给 AI 的上下文卡片)-->
<view class="inline-ref-card inline-ref-card--user" wx:if="{{item.referenceCard}}">
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
</view>
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
<view class="inline-ref-data">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{entry.key}}</text>
<text class="ref-data-value">{{entry.value}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- AI 消息:左对齐白色 -->
<view class="message-row message-assistant" wx:else id="msg-{{item.id}}">
<view class="ai-avatar">
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
</view>
<view class="bubble-col">
<view class="bubble bubble-assistant">
<text class="bubble-text">{{item.content}}</text>
</view>
</view>
</view>
</block>
<!-- AI 正在输入指示器 -->
<view class="message-row message-assistant" wx:if="{{isStreaming && !streamingContent}}" id="msg-typing">
<view class="ai-avatar">
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
</view>
<view class="bubble bubble-assistant typing-bubble">
<view class="typing-dots">
<view class="dot dot-1"></view>
<view class="dot dot-2"></view>
<view class="dot dot-3"></view>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="scroll-bottom-spacer" id="scroll-bottom"></view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="input-bar" style="bottom: {{inputBarBottom}}px;">
<view class="input-wrapper">
<input
class="chat-input"
value="{{inputText}}"
placeholder="输入消息..."
placeholder-class="input-placeholder"
confirm-type="send"
bindinput="onInputChange"
bindconfirm="onSendMessage"
bindfocus="onInputFocus"
bindblur="onInputBlur"
adjust-position="{{false}}"
disabled="{{isStreaming}}"
cursor-spacing="16"
/>
</view>
<view
class="send-btn {{inputText.length > 0 && !isStreaming ? 'send-btn-active' : 'send-btn-disabled'}}"
hover-class="send-btn--hover"
bindtap="onSendMessage"
>
<image
wx:if="{{inputText.length > 0 && !isStreaming}}"
src="/assets/icons/send-arrow-white.svg"
class="send-icon"
mode="aspectFit"
/>
<image
wx:else
src="/assets/icons/send-arrow-gray.svg"
class="send-icon"
mode="aspectFit"
/>
</view>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,372 @@
/* pages/chat/chat.wxss — AI 对话页样式 */
page {
background-color: var(--color-gray-1, #f3f3f3);
}
/* ========== 错误态 ========== */
.page-error {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--color-gray-1, #f3f3f3);
}
.error-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
}
.error-text {
font-size: 28rpx;
color: var(--color-gray-8, #777777);
}
.retry-btn {
padding: 16rpx 48rpx;
background: var(--color-primary, #0052d9);
border-radius: 22rpx;
}
.retry-btn--hover { opacity: 0.8; }
.retry-btn-text {
font-size: 28rpx;
color: #ffffff;
}
/* ========== 页面容器 ========== */
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--color-gray-1, #f3f3f3);
position: relative;
}
/* ========== 消息列表 ========== */
.message-list {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 112rpx;
padding: 24rpx 28rpx;
box-sizing: border-box;
}
.scroll-bottom-spacer {
height: 160rpx;
}
/* ========== 引用内容卡片 ========== */
.reference-card {
background: #ecf2fe;
border-radius: 20rpx;
border-left: 6rpx solid #0052d9;
padding: 22rpx 26rpx;
margin-bottom: 28rpx;
}
.reference-label-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 10rpx;
}
.reference-quote-icon {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.reference-tag {
font-size: 20rpx;
line-height: 29rpx;
font-weight: 600;
color: #0052d9;
background: rgba(0, 82, 217, 0.10);
padding: 2rpx 12rpx;
border-radius: 8rpx;
}
.reference-source {
font-size: 20rpx;
line-height: 29rpx;
color: #5e5e5e;
}
.reference-summary {
font-size: 24rpx;
line-height: 36rpx;
color: #393939;
font-weight: 500;
}
/* ========== 空对话提示 ========== */
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 160rpx;
gap: 18rpx;
}
.empty-ai-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
box-shadow: 0 12rpx 32rpx rgba(102, 126, 234, 0.30);
}
.empty-ai-img {
width: 72rpx;
height: 72rpx;
}
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.empty-sub {
font-size: 26rpx;
color: var(--color-gray-6, #a6a6a6);
}
/* ========== IM 时间分割线 ========== */
.time-divider {
display: flex;
align-items: center;
justify-content: center;
margin: 20rpx 0 8rpx;
padding: 0 28rpx;
}
.time-divider-inner {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6rpx 20rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 20rpx;
margin: 12rpx;
}
.time-divider-text {
font-size: 20rpx;
color: var(--color-gray-7, #8b8b8b);
line-height: 28rpx;
font-variant-numeric: tabular-nums;
}
/* ========== IM 消息行 ========== */
.message-row {
display: flex;
margin-bottom: 28rpx;
}
.message-user {
justify-content: flex-end;
}
.message-assistant {
justify-content: flex-start;
gap: 16rpx;
}
/* ========== AI 头像 ========== */
.ai-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 6rpx 16rpx rgba(102, 126, 234, 0.25);
overflow: hidden;
}
.ai-avatar-img {
width: 46rpx;
height: 46rpx;
}
/* ========== 气泡 ========== */
.bubble-wrapper {
max-width: 80%;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.bubble {
padding: 20rpx 28rpx;
word-break: break-all;
}
.bubble-text {
font-size: 28rpx;
line-height: 1.65;
}
.bubble-user {
max-width: 80%;
background-color: var(--color-primary, #0052d9);
border-radius: 32rpx 8rpx 32rpx 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.22);
}
.bubble-user .bubble-text { color: #ffffff; }
.bubble-assistant {
background-color: #ffffff;
border-radius: 8rpx 32rpx 32rpx 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
}
.bubble-assistant .bubble-text { color: var(--color-gray-13, #242424); }
/* ========== AI 内联引用卡片 ========== */
.inline-ref-card {
background-color: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
border-left: 6rpx solid var(--color-primary, #0052d9);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
max-width: 80%;
}
/* 用户侧引用卡片:右对齐,挂在用户气泡下方 */
.user-bubble-col {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
max-width: 80%;
}
.inline-ref-card--user {
border-left: none;
border-right: 6rpx solid var(--color-primary, #0052d9);
background-color: #ecf2fe;
max-width: 100%;
}
.inline-ref-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 8rpx;
}
.inline-ref-type {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
font-weight: 600;
}
.inline-ref-title {
font-size: 24rpx;
color: var(--color-gray-13, #242424);
font-weight: 500;
}
.inline-ref-summary {
font-size: 22rpx;
color: var(--color-gray-8, #777777);
margin-bottom: 12rpx;
display: block;
line-height: 1.5;
}
.inline-ref-data {
display: flex;
flex-wrap: wrap;
gap: 8rpx 24rpx;
}
.ref-data-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.ref-data-key {
font-size: 20rpx;
color: var(--color-gray-7, #8b8b8b);
}
.ref-data-value {
font-size: 20rpx;
color: var(--color-gray-13, #242424);
font-weight: 500;
}
/* ========== 打字指示器 ========== */
.typing-bubble { padding: 20rpx 32rpx; }
.typing-dots {
display: flex;
align-items: center;
gap: 8rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: var(--color-gray-6, #a6a6a6);
animation: typingBounce 1.4s infinite ease-in-out;
}
.dot-1 { animation-delay: 0s; }
.dot-2 { animation-delay: 0.2s; }
.dot-3 { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ========== 底部输入区域 ========== */
.input-bar {
position: fixed;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx 32rpx;
background-color: #ffffff;
border-top: 2rpx solid var(--color-gray-2, #eeeeee);
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
z-index: 100;
transition: bottom 0.25s ease;
}
.input-wrapper {
flex: 1;
background-color: var(--color-gray-1, #f3f3f3);
border-radius: 48rpx;
padding: 18rpx 28rpx;
min-height: 72rpx;
display: flex;
align-items: center;
}
.chat-input {
width: 100%;
font-size: 28rpx;
color: var(--color-gray-13, #242424);
line-height: 1.4;
background: transparent;
}
.input-placeholder {
color: var(--color-gray-6, #a6a6a6);
font-size: 28rpx;
}
.send-btn {
width: 88rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s, transform 0.15s;
}
.send-btn-active {
background: var(--color-primary, #0052d9);
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.28);
}
.send-btn-disabled {
background-color: var(--color-gray-3, #e0e0e0);
}
.send-btn--hover {
opacity: 0.75;
transform: scale(0.95);
}
.send-icon {
width: 44rpx;
height: 44rpx;
}

View File

@@ -0,0 +1,15 @@
{
"navigationBarTitleText": "助教详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
"note-modal": "/components/note-modal/note-modal",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,540 @@
import { mockCoaches } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
const SHINE_SPEED = 70
const SPARK_DELAY_MS = -150
const SPARK_DUR_MS = 1400
const NEXT_LOOP_DELAY_MS = 400
const SHINE_WIDTH_RPX = 120
const TRACK_WIDTH_RPX = 634
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
interface TickItem {
value: number
label: string
left: string
highlight: boolean
}
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2,
}))
}
/** 助教详情(含绩效、收入、任务、客户关系等) */
interface CoachDetail {
id: string
name: string
avatar: string
level: string
skills: string[]
workYears: number
customerCount: number
hireDate: string
performance: {
monthlyHours: number
monthlySalary: number
customerBalance: number
tasksCompleted: number
/** 绩效档位 */
perfCurrent: number
perfTarget: number
}
income: {
thisMonth: IncomeItem[]
lastMonth: IncomeItem[]
}
notes: NoteItem[]
}
interface IncomeItem {
label: string
amount: string
color: string
}
interface NoteItem {
id: string
content: string
timestamp: string
score: number
customerName: string
tagLabel: string
createdAt: string
}
interface TaskItem {
typeLabel: string
typeClass: string
customerName: string
noteCount: number
pinned: boolean
notes?: Array<{ pinned?: boolean; text: string; date: string }>
}
interface AbandonedTask {
customerName: string
reason: string
}
interface TopCustomer {
id: string
name: string
initial: string
avatarGradient: string
heartEmoji: string
score: string
scoreColor: string
serviceCount: number
balance: string
consume: string
}
interface ServiceRecord {
customerId?: string
customerName: string
initial: string
avatarGradient: string
type: string
typeClass: string
table: string
duration: string
income: string
date: string
perfHours?: string
}
interface HistoryMonth {
month: string
estimated: boolean
customers: string
hours: string
salary: string
callbackDone: number
recallDone: number
}
/** Mock 数据 */
const mockCoachDetail: CoachDetail = {
id: 'coach-001',
name: '小燕',
avatar: '/assets/images/avatar-coach.png',
level: '星级',
skills: ['中🎱', '🎯斯诺克'],
workYears: 3,
customerCount: 68,
hireDate: '2023-03-15',
performance: {
monthlyHours: 87.5,
monthlySalary: 6950,
customerBalance: 86200,
tasksCompleted: 38,
perfCurrent: 80,
perfTarget: 100,
},
income: {
thisMonth: [
{ label: '基础课时费', amount: '¥3,500', color: 'primary' },
{ label: '激励课时费', amount: '¥1,800', color: 'success' },
{ label: '充值提成', amount: '¥1,200', color: 'warning' },
{ label: '酒水提成', amount: '¥450', color: 'purple' },
],
lastMonth: [
{ label: '基础课时费', amount: '¥3,800', color: 'primary' },
{ label: '激励课时费', amount: '¥1,900', color: 'success' },
{ label: '充值提成', amount: '¥1,100', color: 'warning' },
{ label: '酒水提成', amount: '¥400', color: 'purple' },
],
},
notes: [
{ id: 'n1', content: '本月表现优秀,客户好评率高', timestamp: '2026-03-05T14:30:00', score: 9, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-03-05 14:30' },
{ id: 'n2', content: '需要加强斯诺克教学技巧', timestamp: '2026-02-28T10:00:00', score: 7, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-02-28 10:00' },
{ id: 'n3', content: '客户王先生反馈服务态度很好', timestamp: '2026-02-20T16:45:00', score: 8, customerName: '王先生', tagLabel: '王先生', createdAt: '2026-02-20 16:45' },
],
}
const mockVisibleTasks: TaskItem[] = [
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '王先生', noteCount: 2, pinned: true, notes: [{ pinned: true, text: '重点客户,每周必须联系', date: '2026-02-06' }, { text: '上次来说最近出差多', date: '2026-02-01' }] },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '李女士', noteCount: 0, pinned: true },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '陈女士', noteCount: 1, pinned: true, notes: [{ text: '喜欢斯诺克,周末常来', date: '2026-01-28' }] },
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '张先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '赵总', noteCount: 3, pinned: false, notes: [{ pinned: true, text: '大客户,注意维护关系', date: '2026-02-03' }, { text: '上次带了3个朋友来', date: '2026-01-25' }, { text: '喜欢VIP包厢', date: '2026-01-15' }] },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '周女士', noteCount: 0, pinned: false },
]
const mockHiddenTasks: TaskItem[] = [
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '刘先生', noteCount: 0, pinned: false },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '孙先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '吴女士', noteCount: 0, pinned: false },
]
const mockAbandonedTasks: AbandonedTask[] = [
{ customerName: '吴先生', reason: '客户拒绝' },
{ customerName: '郑女士', reason: '超时未响应' },
]
const mockTopCustomers: TopCustomer[] = [
{ id: 'c1', name: '王先生', initial: '王', avatarGradient: 'pink', heartEmoji: '❤️', score: '9.5', scoreColor: 'success', serviceCount: 25, balance: '¥8,600', consume: '¥12,800' },
{ id: 'c2', name: '李女士', initial: '李', avatarGradient: 'amber', heartEmoji: '❤️', score: '9.2', scoreColor: 'success', serviceCount: 22, balance: '¥6,200', consume: '¥9,500' },
{ id: 'c3', name: '陈女士', initial: '陈', avatarGradient: 'green', heartEmoji: '❤️', score: '8.5', scoreColor: 'warning', serviceCount: 18, balance: '¥5,000', consume: '¥7,200' },
{ id: 'c4', name: '张先生', initial: '张', avatarGradient: 'blue', heartEmoji: '💛', score: '7.8', scoreColor: 'warning', serviceCount: 12, balance: '¥3,800', consume: '¥5,600' },
{ id: 'c5', name: '赵先生', initial: '赵', avatarGradient: 'violet', heartEmoji: '💛', score: '6.8', scoreColor: 'gray', serviceCount: 8, balance: '¥2,000', consume: '¥3,200' },
{ id: 'c6', name: '刘女士', initial: '刘', avatarGradient: 'pink', heartEmoji: '💛', score: '6.5', scoreColor: 'gray', serviceCount: 7, balance: '¥1,800', consume: '¥2,900' },
{ id: 'c7', name: '孙先生', initial: '孙', avatarGradient: 'teal', heartEmoji: '💛', score: '6.2', scoreColor: 'gray', serviceCount: 6, balance: '¥1,500', consume: '¥2,500' },
{ id: 'c8', name: '周女士', initial: '周', avatarGradient: 'amber', heartEmoji: '💛', score: '6.0', scoreColor: 'gray', serviceCount: 6, balance: '¥1,400', consume: '¥2,200' },
{ id: 'c9', name: '吴先生', initial: '吴', avatarGradient: 'blue', heartEmoji: '💛', score: '5.8', scoreColor: 'gray', serviceCount: 5, balance: '¥1,200', consume: '¥2,000' },
{ id: 'c10', name: '郑女士', initial: '郑', avatarGradient: 'green', heartEmoji: '💛', score: '5.5', scoreColor: 'gray', serviceCount: 5, balance: '¥1,000', consume: '¥1,800' },
{ id: 'c11', name: '冯先生', initial: '冯', avatarGradient: 'violet', heartEmoji: '🤍', score: '5.2', scoreColor: 'gray', serviceCount: 4, balance: '¥900', consume: '¥1,600' },
{ id: 'c12', name: '褚女士', initial: '褚', avatarGradient: 'pink', heartEmoji: '🤍', score: '5.0', scoreColor: 'gray', serviceCount: 4, balance: '¥800', consume: '¥1,400' },
{ id: 'c13', name: '卫先生', initial: '卫', avatarGradient: 'amber', heartEmoji: '🤍', score: '4.8', scoreColor: 'gray', serviceCount: 3, balance: '¥700', consume: '¥1,200' },
{ id: 'c14', name: '蒋女士', initial: '蒋', avatarGradient: 'teal', heartEmoji: '🤍', score: '4.5', scoreColor: 'gray', serviceCount: 3, balance: '¥600', consume: '¥1,000' },
{ id: 'c15', name: '沈先生', initial: '沈', avatarGradient: 'blue', heartEmoji: '🤍', score: '4.2', scoreColor: 'gray', serviceCount: 3, balance: '¥500', consume: '¥900' },
{ id: 'c16', name: '韩女士', initial: '韩', avatarGradient: 'green', heartEmoji: '🤍', score: '4.0', scoreColor: 'gray', serviceCount: 2, balance: '¥400', consume: '¥800' },
{ id: 'c17', name: '杨先生', initial: '杨', avatarGradient: 'violet', heartEmoji: '🤍', score: '3.8', scoreColor: 'gray', serviceCount: 2, balance: '¥300', consume: '¥700' },
{ id: 'c18', name: '朱女士', initial: '朱', avatarGradient: 'pink', heartEmoji: '🤍', score: '3.5', scoreColor: 'gray', serviceCount: 2, balance: '¥200', consume: '¥600' },
{ id: 'c19', name: '秦先生', initial: '秦', avatarGradient: 'amber', heartEmoji: '🤍', score: '3.2', scoreColor: 'gray', serviceCount: 1, balance: '¥100', consume: '¥500' },
{ id: 'c20', name: '尤女士', initial: '尤', avatarGradient: 'teal', heartEmoji: '🤍', score: '3.0', scoreColor: 'gray', serviceCount: 1, balance: '¥0', consume: '¥400' },
]
const mockServiceRecords: ServiceRecord[] = [
{ customerId: 'c1', customerName: '王先生', initial: '王', avatarGradient: 'pink', type: '基础课', typeClass: 'basic', table: 'A12号台', duration: '2.5h', income: '¥200', date: '2026-02-07 21:30' },
{ customerId: 'c2', customerName: '李女士', initial: '李', avatarGradient: 'amber', type: '激励课', typeClass: 'incentive', table: 'VIP1号房', duration: '1.5h', income: '¥150', date: '2026-02-07 19:00', perfHours: '2h' },
{ customerId: 'c3', customerName: '陈女士', initial: '陈', avatarGradient: 'green', type: '基础课', typeClass: 'basic', table: '2号台', duration: '2h', income: '¥160', date: '2026-02-06 20:00' },
{ customerId: 'c4', customerName: '张先生', initial: '张', avatarGradient: 'blue', type: '激励课', typeClass: 'incentive', table: '5号台', duration: '1h', income: '¥80', date: '2026-02-05 14:00' },
]
const mockHistoryMonths: HistoryMonth[] = [
{ month: '本月', estimated: true, customers: '22人', hours: '87.5h', salary: '¥6,950', callbackDone: 14, recallDone: 24 },
{ month: '上月', estimated: false, customers: '25人', hours: '92.0h', salary: '¥7,200', callbackDone: 16, recallDone: 28 },
{ month: '4月', estimated: false, customers: '20人', hours: '85.0h', salary: '¥6,600', callbackDone: 12, recallDone: 22 },
{ month: '3月', estimated: false, customers: '18人', hours: '78.5h', salary: '¥6,100', callbackDone: 10, recallDone: 18 },
{ month: '2月', estimated: false, customers: '15人', hours: '65.0h', salary: '¥5,200', callbackDone: 8, recallDone: 15 },
]
Page({
data: {
/** 页面状态:四态 */
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 助教 ID */
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** 绩效指标卡片 */
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
/** 绩效进度 */
perfCurrent: 0,
perfTarget: 100,
perfGap: 0,
perfPercent: 0,
/** 收入明细 Tab */
incomeTab: 'this' as 'this' | 'last',
currentIncome: [] as IncomeItem[],
incomeTotal: '',
/** 任务执行 */
taskStats: { recall: 24, callback: 14 },
visibleTasks: [] as TaskItem[],
hiddenTasks: [] as TaskItem[],
abandonedTasks: [] as AbandonedTask[],
tasksExpanded: false,
/** 客户关系 TOP20 */
topCustomers: [] as TopCustomer[],
topCustomersExpanded: false,
/** 近期服务明细 */
serviceRecords: [] as ServiceRecord[],
/** 更多信息 */
historyMonths: [] as HistoryMonth[],
/** 备注 */
sortedNotes: [] as NoteItem[],
noteModalVisible: false,
/** 备注列表弹窗 */
notesPopupVisible: false,
notesPopupName: '',
notesPopupList: [] as Array<{ pinned?: boolean; text: string; date: string }>,
/** 进度条动画状态(驱动 perf-progress-bar 组件) */
pbFilledPct: 0,
pbClampedSparkPct: 0,
pbCurrentTier: 0,
pbTicks: [] as TickItem[],
pbShineRunning: false,
pbSparkRunning: false,
pbShineDurMs: 1000,
pbSparkDurMs: SPARK_DUR_MS,
},
_longPressed: false,
_animTimer: null as ReturnType<typeof setTimeout> | null,
onLoad(options: { id?: string }) {
const id = options?.id || ''
this.setData({ coachId: id })
this.loadData(id)
},
onHide() {
this._stopAnimLoop()
},
onUnload() {
this._stopAnimLoop()
},
onShow() {
if (this.data.pageState === 'normal' && !this._animTimer) {
this._startAnimLoop()
}
},
_startAnimLoop() {
this._stopAnimLoop()
this._runAnimStep()
},
_stopAnimLoop() {
if (this._animTimer !== null) {
clearTimeout(this._animTimer)
this._animTimer = null
}
this.setData({ pbShineRunning: false, pbSparkRunning: false })
},
_runAnimStep() {
const filledPct = this.data.pbFilledPct ?? 0
const shineDurMs = calcShineDur(filledPct)
this.setData({ pbShineRunning: true, pbSparkRunning: false, pbShineDurMs: shineDurMs })
const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS)
this._animTimer = setTimeout(() => {
this.setData({ pbSparkRunning: true })
this._animTimer = setTimeout(() => {
this.setData({ pbShineRunning: false, pbSparkRunning: false })
this._animTimer = setTimeout(() => {
this._runAnimStep()
}, Math.max(0, NEXT_LOOP_DELAY_MS))
}, SPARK_DUR_MS)
}, sparkTriggerDelay)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
// TODO: 替换为真实 API 调用 GET /api/coaches/:id
const basicCoach = mockCoaches.find((c) => c.id === id)
const detail: CoachDetail = basicCoach
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
: mockCoachDetail
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const perf = detail.performance
const perfCards = [
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
const perfGap = perf.perfTarget - perf.perfCurrent
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
// 进度条组件数据
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
const maxHours = 220
const totalHours = perf.monthlyHours
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
// 当前档位
let pbCurrentTier = 0
for (let i = 1; i < tierNodes.length; i++) {
if (totalHours >= tierNodes[i]) pbCurrentTier = i
else break
}
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
this.setData({
pageState: 'normal',
detail,
perfCards,
perfCurrent: perf.perfCurrent,
perfTarget: perf.perfTarget,
perfGap,
perfPercent,
visibleTasks: mockVisibleTasks,
hiddenTasks: mockHiddenTasks,
abandonedTasks: mockAbandonedTasks,
topCustomers: mockTopCustomers,
serviceRecords: mockServiceRecords,
historyMonths: mockHistoryMonths,
sortedNotes: sorted,
pbFilledPct,
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
pbCurrentTier,
pbTicks: buildTicks(tierNodes, maxHours),
pbShineDurMs: calcShineDur(pbFilledPct),
pbSparkDurMs: SPARK_DUR_MS,
})
this.switchIncomeTab('this')
// 数据加载完成后启动动画循环
setTimeout(() => this._startAnimLoop(), 300)
} catch (_e) {
this.setData({ pageState: 'error' })
}
}, 500)
},
/** 切换收入明细 Tab */
switchIncomeTab(tab: 'this' | 'last') {
const detail = this.data.detail
if (!detail) return
const items = tab === 'this' ? detail.income.thisMonth : detail.income.lastMonth
const total = items.reduce((sum, item) => {
const num = parseFloat(item.amount.replace(/[¥,]/g, ''))
return sum + (isNaN(num) ? 0 : num)
}, 0)
this.setData({
incomeTab: tab,
currentIncome: items,
incomeTotal: `¥${total.toLocaleString()}`,
})
},
/** 点击收入 Tab */
onIncomeTabTap(e: WechatMiniprogram.CustomEvent) {
const tab = e.currentTarget.dataset.tab as 'this' | 'last'
this.switchIncomeTab(tab)
},
/** 展开/收起任务 */
onToggleTasks() {
this.setData({ tasksExpanded: !this.data.tasksExpanded })
},
/** 点击任务项 — 跳转客户详情 */
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
const name = e.currentTarget.dataset.name as string
if (!name) return
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
},
/** 展开/收起客户关系列表 */
onToggleTopCustomers() {
this.setData({ topCustomersExpanded: !this.data.topCustomersExpanded })
},
/** 点击任务备注图标 — 弹出备注列表 */
onTaskNoteTap(e: WechatMiniprogram.CustomEvent) {
const idx = e.currentTarget.dataset.index as number | undefined
const hiddenIdx = e.currentTarget.dataset.hiddenIndex as number | undefined
let task: TaskItem | undefined
if (idx !== undefined) {
task = this.data.visibleTasks[idx]
} else if (hiddenIdx !== undefined) {
task = this.data.hiddenTasks[hiddenIdx]
}
if (task?.notes && task.notes.length > 0) {
this.setData({
notesPopupVisible: true,
notesPopupName: task.customerName,
notesPopupList: task.notes,
})
}
},
/** 关闭备注列表弹窗 */
onHideNotesPopup() {
this.setData({ notesPopupVisible: false })
},
/** 点击客户卡片 — 跳转客户详情 */
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 近期服务明细 — 点击跳转客户详情 */
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看更多服务记录 */
onViewMoreRecords() {
const coachId = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/performance-records/performance-records?coachId=${coachId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 打开备注弹窗 */
onAddNote() {
this.setData({ noteModalVisible: true })
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
const { content } = e.detail
// TODO: 替换为真实 API 调用 POST /api/xcx/notes
const newNote: NoteItem = {
id: `n-${Date.now()}`,
content,
timestamp: new Date().toISOString().slice(0, 16).replace('T', ' '),
score: 0,
customerName: '我',
tagLabel: '我',
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
const notes = [newNote, ...this.data.sortedNotes]
this.setData({ noteModalVisible: false, sortedNotes: notes })
wx.showToast({ title: '备注已保存', icon: 'success' })
},
/** 备注弹窗取消 */
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** 重试 */
onRetry() {
const id = this.data.coachId || ''
this.loadData(id)
},
/** 问问助手 */
onStartChat() {
const id = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?coachId=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
})

View File

@@ -0,0 +1,331 @@
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{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 区域 -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
<view class="banner-overlay">
<view class="coach-header">
<view class="avatar-box">
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
</view>
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{detail.name}}</text>
<coach-level-tag level="{{detail.level}}" />
</view>
<view class="skill-row">
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
</view>
</view>
<view class="info-right-stats">
<view class="right-stat">
<text class="right-stat-label">工龄</text>
<text class="right-stat-value">{{detail.workYears}}</text>
<text class="right-stat-label">年</text>
</view>
<view class="right-stat">
<text class="right-stat-label">客户</text>
<text class="right-stat-value">{{detail.customerCount}}</text>
<text class="right-stat-label">人</text>
</view>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content">
<!-- 绩效概览 -->
<view class="card">
<view class="card-title-row">
<text class="section-title title-blue">绩效概览</text>
</view>
<view class="perf-grid">
<view class="perf-card {{item.bgClass}}" wx:for="{{perfCards}}" wx:key="label">
<text class="perf-label">{{item.label}}</text>
<view class="perf-value-row">
<text class="perf-value {{item.valueColor}}">{{item.value}}</text>
<text class="perf-unit" wx:if="{{item.unit}}">{{item.unit}}</text>
</view>
<text class="perf-sub">{{item.sub}}</text>
</view>
</view>
<view class="perf-progress-box">
<view class="perf-progress-header">
<text class="perf-progress-label">绩效档位进度</text>
<text class="perf-progress-hint">距下一档还差 {{perfGap}}h</text>
</view>
<perf-progress-bar
filledPct="{{pbFilledPct}}"
clampedSparkPct="{{pbClampedSparkPct}}"
currentTier="{{pbCurrentTier}}"
ticks="{{pbTicks}}"
shineRunning="{{pbShineRunning}}"
sparkRunning="{{pbSparkRunning}}"
shineDurMs="{{pbShineDurMs}}"
sparkDurMs="{{pbSparkDurMs}}"
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
/>
</view>
</view>
<!-- 收入明细 -->
<view class="card">
<view class="card-header">
<text class="section-title title-green">收入明细</text>
<view class="income-tabs">
<view class="income-tab {{incomeTab === 'this' ? 'active' : ''}}" data-tab="this" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
<text>本月</text>
<text class="income-tab-est" wx:if="{{incomeTab === 'this'}}">预估</text>
</view>
<view class="income-tab {{incomeTab === 'last' ? 'active' : ''}}" data-tab="last" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
<text>上月</text>
</view>
</view>
</view>
<view class="income-list">
<view class="income-item" wx:for="{{currentIncome}}" wx:key="label">
<view class="income-dot dot-{{item.color}}"></view>
<text class="income-label">{{item.label}}</text>
<text class="income-amount">{{item.amount}}</text>
</view>
<view class="income-total">
<text class="income-total-label">合计{{incomeTab === 'this' ? '(预估)' : ''}}</text>
<text class="income-total-value">{{incomeTotal}}</text>
</view>
</view>
</view>
<!-- 任务执行 -->
<view class="card">
<view class="card-header">
<text class="section-title title-orange">任务执行</text>
<view class="task-summary">
<text class="task-summary-label">本月完成</text>
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}</text>个</text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text>个</text>
</view>
</view>
<view class="task-list">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
<t-icon name="chat" size="32rpx" color="#777777" />
<text class="task-note-count">{{item.noteCount}}</text>
</view>
<text class="task-pin" wx:if="{{item.pinned}}">📌</text>
</view>
</view>
<block wx:if="{{tasksExpanded}}">
<view class="task-list task-list-extra">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
<t-icon name="chat" size="32rpx" color="#777777" />
<text class="task-note-count">{{item.noteCount}}</text>
</view>
</view>
<view class="task-item task-item-abandoned" wx:for="{{abandonedTasks}}" wx:key="index">
<text class="task-abandoned-name">{{item.customerName}}</text>
<text class="task-abandoned-reason">{{item.reason}}</text>
</view>
</view>
</block>
<view class="task-toggle" bindtap="onToggleTasks" hover-class="task-toggle--hover" wx:if="{{hiddenTasks.length > 0 || abandonedTasks.length > 0}}">
<text>{{tasksExpanded ? '收起 ↑' : '展开全部 ↓'}}</text>
</view>
</view>
<!-- 客户关系 TOP20 -->
<view class="card">
<view class="card-header">
<text class="section-title title-pink">客户关系 TOP20</text>
<text class="header-hint">近60天</text>
</view>
<view class="top-customer-list">
<view
class="top-customer-item"
hover-class="top-customer-item--hover"
wx:for="{{topCustomers}}"
wx:key="id"
wx:if="{{topCustomersExpanded || index < 5}}"
data-id="{{item.id}}"
bindtap="onCustomerTap"
>
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
<text class="top-customer-avatar-text">{{item.initial}}</text>
</view>
<view class="top-customer-info">
<view class="top-customer-name-row">
<text class="top-customer-name">{{item.name}}</text>
<text class="top-customer-heart">{{item.heartEmoji}}</text>
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{item.score}}</text>
</view>
<view class="top-customer-stats">
<text class="top-customer-stat">服务 <text class="top-customer-stat-val">{{item.serviceCount}}</text>次</text>
<text class="top-customer-stat">储值 <text class="top-customer-stat-val">{{item.balance}}</text></text>
<text class="top-customer-stat">消费 <text class="top-customer-stat-val">{{item.consume}}</text></text>
</view>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
</view>
<view class="toggle-btn" bindtap="onToggleTopCustomers" hover-class="toggle-btn--hover" wx:if="{{topCustomers.length > 5}}">
<text>{{topCustomersExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
</view>
</view>
<!-- 近期服务明细 -->
<view class="card">
<view class="card-title-row">
<text class="section-title title-purple">近期服务明细</text>
</view>
<view class="svc-list">
<view class="svc-card" wx:for="{{serviceRecords}}" wx:key="index"
bindtap="onSvcCardTap" data-id="{{item.customerId}}" hover-class="svc-card--hover">
<!-- 头像列 -->
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
<text class="top-customer-avatar-text">{{item.initial}}</text>
</view>
<!-- 右侧内容列:两行垂直排列 -->
<view class="svc-content">
<!-- 第1行客户名 + 类型标签 + 日期 -->
<view class="svc-row1">
<text class="svc-customer">{{item.customerName}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
<!-- 第2行台号 + 时长 + 绩效 + 收入 -->
<view class="svc-row2">
<view class="svc-row2-left">
<text class="svc-table-tag">{{item.table}}</text>
<text class="svc-duration">{{item.duration}}</text>
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
</view>
<text class="svc-income">{{item.income}}</text>
</view>
</view>
</view>
</view>
<view class="svc-more" bindtap="onViewMoreRecords" hover-class="svc-more--hover">
<text>查看更多服务记录 →</text>
</view>
</view>
<!-- 更多信息 -->
<view class="card">
<view class="card-title-row">
<text class="section-title title-teal">更多信息</text>
</view>
<view class="more-info-row">
<text class="more-info-label">入职日期</text>
<text class="more-info-value">{{detail.hireDate}}</text>
</view>
<view class="history-table">
<view class="history-thead">
<text class="history-th history-th-left">月份</text>
<text class="history-th">服务客户</text>
<text class="history-th">访/召完成</text>
<text class="history-th">业绩时长</text>
<text class="history-th">工资</text>
</view>
<view class="history-row {{index === 0 ? 'history-row-current' : ''}}" wx:for="{{historyMonths}}" wx:key="month">
<view class="history-td history-td-left">
<text>{{item.month}}</text>
<text class="history-est" wx:if="{{item.estimated}}">预估</text>
</view>
<text class="history-td">{{item.customers}}</text>
<text class="history-td">{{item.callbackDone}} | {{item.recallDone}}</text>
<text class="history-td {{index === 0 ? 'text-primary' : ''}}">{{item.hours}}</text>
<text class="history-td {{index === 0 ? 'text-success' : ''}}">{{item.salary}}</text>
</view>
</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.tagLabel}}</text>
<text class="note-time">{{item.createdAt}}</text>
</view>
<text class="note-content">{{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}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
<!-- 备注列表弹窗 -->
<view class="notes-popup-overlay" wx:if="{{notesPopupVisible}}" catchtap="onHideNotesPopup">
<view class="notes-popup" catchtap="">
<view class="notes-popup-header">
<text class="notes-popup-title">{{notesPopupName}} 的备注</text>
<view class="notes-popup-close" bindtap="onHideNotesPopup" hover-class="notes-popup-close--hover">
<t-icon name="close" size="40rpx" color="#8b8b8b" />
</view>
</view>
<view class="notes-popup-list">
<view class="notes-popup-item" wx:for="{{notesPopupList}}" wx:key="index">
<view class="notes-popup-item-top">
<text wx:if="{{item.pinned}}">📌</text>
<text class="notes-popup-item-date">{{item.date}}</text>
</view>
<text class="notes-popup-item-text">{{item.text}}</text>
</view>
</view>
</view>
</view>
</block>
<dev-fab />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"navigationBarTitleText": "客户详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"note-modal": "/components/note-modal/note-modal",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"clue-card": "/components/clue-card/clue-card",
"ai-title-badge": "/components/ai-title-badge/ai-title-badge"
}
}

View File

@@ -0,0 +1,297 @@
import { mockCustomerDetail } from "../../utils/mock-data"
interface ConsumptionRecord {
id: string
type: "table" | "shop" | "recharge"
date: string
tableName?: string
startTime?: string
endTime?: string
duration?: string
tableFee?: number
tableOrigPrice?: number
coaches?: Array<{
name: string
level: string
levelColor: string
courseType: string
hours: string
perfHours?: string
fee: number
}>
foodAmount?: number
foodOrigPrice?: number
totalAmount?: number
totalOrigPrice?: number
payMethod?: string
rechargeAmount?: number
}
const mockRecords: ConsumptionRecord[] = [
{
id: "r1",
type: "table",
date: "2026-02-05",
tableName: "A12号台",
startTime: "21:30",
endTime: "00:50",
duration: "3h 20min",
tableFee: 180,
tableOrigPrice: 240,
coaches: [
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "2.5h", fee: 200 },
{ name: "Amy", level: "junior", levelColor: "green", courseType: "激励课", hours: "0.5h", perfHours: "1h", fee: 50 },
],
foodAmount: 210,
foodOrigPrice: 260,
totalAmount: 640,
totalOrigPrice: 750,
},
{
id: "r2",
type: "table",
date: "2026-02-01",
tableName: "888号台",
startTime: "14:00",
endTime: "16:00",
duration: "2h 00min",
tableFee: 120,
coaches: [
{ name: "泡芙", level: "middle", levelColor: "purple", courseType: "激励课", hours: "1.5h", perfHours: "2h", fee: 100 },
],
totalAmount: 220,
},
{
id: "r3",
type: "shop",
date: "2026-01-28",
coaches: [
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "1h", fee: 100 },
],
foodAmount: 180,
totalAmount: 280,
},
]
Page({
data: {
pageState: "loading" as "loading" | "empty" | "error" | "normal",
detail: {
id: "cust_001",
name: "王先生",
avatarChar: "王",
phone: "13812345678",
balance: "8,600",
consumption60d: "2,800",
idealInterval: "7天",
daysSinceVisit: "12天",
},
phoneVisible: false,
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
aiInsight: {
summary: "高价值 VIP 客户,月均到店 4-5 次,偏好夜场中式台球,近期对斯诺克产生兴趣。社交属性强,常带固定球搭子,有拉新能力。储值余额充足,对促销活动响应积极。",
strategies: [
{ color: "green", text: "最后到店距今 12 天,超出理想间隔 7 天,建议尽快安排助教小燕主动联系召回" },
{ color: "amber", text: "客户提到想练斯诺克走位,可推荐斯诺克专项课程包,结合储值优惠提升客单价" },
{ color: "pink", text: "社交属性强,可邀请参加门店球友赛事活动,带动球搭子到店消费" },
],
},
clues: [
{
category: "客户\n基础",
categoryColor: "primary",
text: "🎂 生日 3月15日 · VIP会员 · 注册2年",
source: "系统",
},
{
category: "消费\n习惯",
categoryColor: "success",
text: "🌙 常来夜场 · 月均4-5次",
source: "系统",
},
{
category: "消费\n习惯",
categoryColor: "success",
text: "💰 高客单价",
source: "系统",
detail: "近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%",
},
{
category: "玩法\n偏好",
categoryColor: "purple",
text: "🎱 偏爱中式 · 斯诺克进阶中",
source: "系统",
},
{
category: "促销\n接受",
categoryColor: "warning",
text: "🍷 爱点酒水套餐 · 对储值活动敏感",
source: "系统",
detail: "最近3次到店均点了酒水套餐上次 ¥5000 储值活动当天即充值,对满赠类活动响应率高",
},
{
category: "社交\n关系",
categoryColor: "pink",
text: "👥 常带朋友 · 固定球搭子2人",
source: "系统",
detail: "近60天 80% 的到店为多人局常与「李哥」「阿杰」同行曾介绍2位新客办卡",
},
{
category: "重要\n反馈",
categoryColor: "error",
text: "⚠️ 上次提到想练斯诺克走位对球桌维护质量比较在意建议优先安排VIP房",
source: "小燕",
},
],
coachTasks: [
{
name: "小燕",
level: "senior",
levelColor: "pink",
taskType: "高优先召回",
taskColor: "red",
bgClass: "coach-card-red",
status: "normal",
lastService: "02-20 21:30 · 2.5h",
metrics: [
{ label: "近60天次数", value: "18次", color: "primary" },
{ label: "总时长", value: "17h" },
{ label: "次均时长", value: "0.9h", color: "warning" },
],
},
{
name: "泡芙",
level: "middle",
levelColor: "purple",
taskType: "优先召回",
taskColor: "orange",
bgClass: "coach-card-orange",
status: "pinned",
lastService: "02-15 14:00 · 1.5h",
metrics: [
{ label: "近60天次数", value: "12次", color: "primary" },
{ label: "总时长", value: "11h" },
{ label: "次均时长", value: "0.9h", color: "warning" },
],
},
{
name: "Amy",
level: "junior",
levelColor: "green",
taskType: "关系构建",
taskColor: "pink",
bgClass: "coach-card-pink",
status: "normal",
lastService: "02-10 19:00 · 1.0h",
metrics: [
{ label: "近60天次数", value: "8次", color: "primary" },
{ label: "总时长", value: "6h" },
{ label: "次均时长", value: "0.75h", color: "warning" },
],
},
{
name: "Lucy",
level: "senior",
levelColor: "pink",
taskType: "客户回访",
taskColor: "teal",
bgClass: "coach-card-teal",
status: "abandoned",
lastService: "01-28 20:30 · 2.0h",
metrics: [
{ label: "近60天次数", value: "6次", color: "primary" },
{ label: "总时长", value: "9h" },
{ label: "次均时长", value: "1.5h", color: "warning" },
],
},
],
favoriteCoaches: [
{
emoji: "❤️",
name: "小燕",
relationIndex: "9.2",
indexColor: "success",
bgClass: "fav-card-pink",
stats: [
{ label: "基础", value: "12h", color: "primary" },
{ label: "激励", value: "5h", color: "warning" },
{ label: "上课", value: "18次" },
{ label: "充值", value: "¥5,000", color: "success" },
],
},
{
emoji: "💛",
name: "泡芙",
relationIndex: "7.8",
indexColor: "warning",
bgClass: "fav-card-amber",
stats: [
{ label: "基础", value: "8h", color: "primary" },
{ label: "激励", value: "3h", color: "warning" },
{ label: "上课", value: "12次" },
{ label: "充值", value: "¥3,000", color: "success" },
],
},
],
consumptionRecords: mockRecords,
loadingMore: false,
noteModalVisible: false,
sortedNotes: [
{ id: 'n1', tagLabel: '管理员', createdAt: '2026-03-05 14:30', content: '本月到店积极,对斯诺克课程感兴趣,建议持续跟进推荐相关课程包' },
{ id: 'n2', tagLabel: '小燕', createdAt: '2026-02-20 16:45', content: '客户反馈服务态度很好,提到下次想带朋友一起来' },
{ id: 'n3', tagLabel: '管理员', createdAt: '2026-02-10 10:00', content: '上次储值活动当天即充值 ¥5000对满赠类活动响应积极' },
] as Array<{ id: string; tagLabel: string; createdAt: string; content: string }>,
},
onLoad(options: any) {
// 随机 AI 配色
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
this.setData({ aiColor })
this.loadDetail()
},
loadDetail() {
this.setData({ pageState: "normal" })
},
onRetry() {
this.loadDetail()
},
/** 查看/隐藏手机号 */
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
onCopyPhone() {
const phone = this.data.detail.phone
wx.setClipboardData({
data: phone,
success: () => {
wx.showToast({ title: '手机号码已复制', icon: 'none' })
},
})
},
onViewServiceRecords() {
wx.navigateTo({ url: "/pages/customer-service-records/customer-service-records" })
},
onStartChat() {
wx.navigateTo({ url: "/pages/chat/chat" })
},
onAddNote() {
this.setData({ noteModalVisible: true })
},
onNoteConfirm(e: any) {
this.setData({ noteModalVisible: false })
},
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
})

View File

@@ -0,0 +1,323 @@
<!-- pages/customer-detail/customer-detail.wxml -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<view class="page-empty" wx:elif="{{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.phone : '138****5678'}}</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">¥{{detail.balance}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">¥{{detail.consumption60d}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{detail.idealInterval}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{detail.daysSinceVisit}}</text>
<text class="stat-label">距今到店</text>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content" >
<!-- AI 智能洞察 -->
<view class="ai-insight-card">
<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">{{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">
<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.category}}"
category="{{item.categoryColor}}"
emoji=""
title="{{item.text}}"
source="By:{{item.source}}"
content="{{item.detail}}"
/>
</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">
<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">上次服务:{{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 : ''}}">{{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">
<view class="fav-coach-card {{item.bgClass}}" wx:for="{{favoriteCoaches}}" wx:key="index">
<view class="fav-coach-top">
<view class="fav-coach-name">
<text class="fav-emoji">{{item.emoji}}</text>
<text class="fav-name">{{item.name}}</text>
</view>
<view class="fav-index">
<text class="fav-index-label">关系指数</text>
<text class="fav-index-value text-{{item.indexColor}}">{{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 : ''}}">{{s.value}}</text>
</view>
</view>
</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">{{item.tableName}}</text>
</view>
<text class="record-date">{{item.date}}</text>
</view>
<view class="record-time-row">
<view class="record-time-left">
<text class="record-time-text">{{item.startTime}}</text>
<text class="record-time-arrow">→</text>
<text class="record-time-text">{{item.endTime}}</text>
<text class="record-duration-tag">{{item.duration}}</text>
</view>
<view class="record-fee-right">
<text class="record-fee-amount">¥{{item.tableFee}}</text>
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">¥{{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">{{c.name}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
<text class="record-coach-fee">¥{{c.fee}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<view class="record-food-right">
<text class="record-food-amount">¥{{item.foodAmount}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">¥{{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">¥{{item.totalAmount}}</text>
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">¥{{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">{{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">{{c.name}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
<text class="record-coach-fee">¥{{c.fee}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-amount">¥{{item.foodAmount}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<text class="record-total-amount">¥{{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.tagLabel}}</text>
<text class="note-time">{{item.createdAt}}</text>
</view>
<text class="note-content">{{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 悬浮按钮 -->
<ai-float-button customerId="{{detail.id}}" />
</block>
<dev-fab />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "服务记录",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"service-record-card": "/components/service-record-card/service-record-card",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty"
}
}

View File

@@ -0,0 +1,264 @@
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data'
import type { ConsumptionRecord } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
interface ServiceRecord extends ConsumptionRecord {
/** 台桌号,如 "A12号台" */
table: string
/** 课程类型标签,如 "基础课" */
type: string
/** 课程样式 class 后缀basic / vip / tip / recharge */
typeClass: 'basic' | 'vip' | 'tip' | 'recharge'
/** 卡片类型course=普通课recharge=充值提成 */
recordType: 'course' | 'recharge'
/** 折算后小时数(原始数字,组件负责加 h 后缀) */
duration: number
/** 折算前小时数(原始数字,组件负责加 h 后缀) */
durationRaw: number
/** 到手金额(原始数字,组件负责加 ¥ 前缀) */
income: number
/** 是否预估金额 */
isEstimate: boolean
/** 商品/饮品描述 */
drinks: string
/** 显示用日期,如 "2月5日 15:00 - 17:00" */
date: string
}
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 客户 ID */
customerId: '',
/** 客户名 */
customerName: '',
/** 客户名首字 */
customerInitial: '',
/** 客户电话(脱敏) */
customerPhone: '139****5678',
/** 客户电话(完整,查看后显示) */
customerPhoneFull: '13900005678',
/** 手机号是否已展开 */
phoneVisible: false,
/** 累计服务次数 */
totalServiceCount: 0,
/** 关系指数 */
relationIndex: '0.85',
/** 当前月份标签 */
monthLabel: '',
/** 当前年 */
currentYear: 2026,
/** 当前月 */
currentMonth: 2,
/** 最小年月(数据起始) */
minYearMonth: 202601,
/** 最大年月(当前月) */
maxYearMonth: 202602,
/** 是否可切换上月 */
canPrev: true,
/** 是否可切换下月 */
canNext: false,
/** 月度统计 */
monthCount: '6次',
monthHours: '11.5h',
monthRelation: '0.85',
/** 当前月的服务记录 */
records: [] as ServiceRecord[],
/** 所有记录(原始) */
allRecords: [] as ConsumptionRecord[],
/** 是否还有更多 */
hasMore: false,
/** 加载更多中 */
loadingMore: false,
},
onLoad(options) {
const id = options?.customerId || options?.id || ''
this.setData({ customerId: id })
this.loadData(id)
},
/** 加载数据 */
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const customer = mockCustomers.find((c) => c.id === id)
const detail = customer
? { ...mockCustomerDetail, id: customer.id, name: customer.name }
: mockCustomerDetail
const allRecords = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
const name = detail.name || '客户'
this.setData({
customerName: name,
customerInitial: name[0] || '?',
allRecords,
totalServiceCount: allRecords.length,
})
this.updateMonthView()
}, 400)
},
/** 根据当前月份筛选并更新视图 */
updateMonthView() {
const { currentYear, currentMonth, allRecords } = this.data
const monthLabel = `${currentYear}${currentMonth}`
// 筛选当月记录
const monthPrefix = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
const monthRecords = allRecords.filter((r) => r.date.startsWith(monthPrefix))
// 转换为展示格式(对齐 task-detail ServiceRecord复用 service-record-card 组件)
// income / duration 均传原始数字,由组件统一加 ¥ 和 h
const records: ServiceRecord[] = monthRecords.map((r) => {
const d = new Date(r.date)
const month = d.getMonth() + 1
const day = d.getDate()
const dateLabel = `${month}${day}`
const timeRange = this.generateTimeRange(r.duration)
const isRecharge = r.project.includes('充值')
return {
...r,
table: this.getTableNo(r.id),
type: this.getTypeLabel(r.project),
typeClass: this.getTypeClass(r.project) as 'basic' | 'vip' | 'tip' | 'recharge',
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
duration: isRecharge ? 0 : parseFloat((r.duration / 60).toFixed(1)),
durationRaw: 0,
income: r.amount,
isEstimate: false,
drinks: '',
date: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
}
})
// 月度统计
const totalMinutes = monthRecords.reduce((sum, r) => sum + r.duration, 0)
const monthCount = monthRecords.length + '次'
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
// 边界判断
const yearMonth = currentYear * 100 + currentMonth
const canPrev = yearMonth > this.data.minYearMonth
const canNext = yearMonth < this.data.maxYearMonth
const isEmpty = records.length === 0 && allRecords.length === 0
this.setData({
monthLabel,
records,
monthCount,
monthHours,
canPrev,
canNext,
pageState: isEmpty ? 'empty' : 'normal',
})
},
/** 生成模拟时间段 */
generateTimeRange(durationMin: number): string {
const startHour = 14 + Math.floor(Math.random() * 6)
const endMin = startHour * 60 + durationMin
const endHour = Math.floor(endMin / 60)
const endMinute = endMin % 60
return `${startHour}:00 - ${endHour}:${String(endMinute).padStart(2, '0')}`
},
/** 课程类型标签 */
getTypeLabel(project: string): string {
if (project.includes('小组')) return '小组课'
if (project.includes('1v1')) return '基础课'
if (project.includes('充值')) return '充值'
if (project.includes('斯诺克')) return '斯诺克'
return '基础课'
},
/** 课程类型样式(对齐 service-record-card typeClass prop*/
getTypeClass(project: string): string {
if (project.includes('充值')) return 'recharge'
if (project.includes('小组')) return 'vip'
if (project.includes('斯诺克')) return 'vip'
return 'basic'
},
/** 模拟台号 */
getTableNo(id: string): string {
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
return tables[idx]
},
/** 切换到上一月 */
onPrevMonth() {
if (!this.data.canPrev) return
let { currentYear, currentMonth } = this.data
currentMonth--
if (currentMonth < 1) {
currentMonth = 12
currentYear--
}
this.setData({ currentYear, currentMonth })
this.updateMonthView()
},
/** 切换到下一月 */
onNextMonth() {
if (!this.data.canNext) return
let { currentYear, currentMonth } = this.data
currentMonth++
if (currentMonth > 12) {
currentMonth = 1
currentYear++
}
this.setData({ currentYear, currentMonth })
this.updateMonthView()
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData(this.data.customerId)
setTimeout(() => wx.stopPullDownRefresh(), 600)
},
/** 重试 */
onRetry() {
this.loadData(this.data.customerId)
},
/** 触底加载 */
onReachBottom() {
// Mock 阶段数据有限,不做分页
if (this.data.loadingMore || !this.data.hasMore) return
this.setData({ loadingMore: true })
setTimeout(() => {
this.setData({ loadingMore: false, hasMore: false })
}, 500)
},
/** 查看/隐藏手机号 */
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
onCopyPhone() {
const phone = this.data.customerPhoneFull
wx.setClipboardData({
data: phone,
success: () => {
wx.showToast({ title: '手机号码已复制', icon: 'none' })
},
})
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,123 @@
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无服务记录" />
</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" hover-class="retry-btn--hover" bindtap="onRetry">重试</view>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner复用 task-detail 样式)-->
<view class="banner-area">
<image src="/assets/images/banner-bg-coral-aurora.svg" class="banner-bg-svg" mode="scaleToFill" />
<view class="banner-content">
<view class="customer-info">
<view class="avatar-box">
<text class="avatar-text">{{customerInitial}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{customerName}}</text>
<view class="name-badges">
<text class="name-badge">服务 <text class="badge-highlight">{{totalServiceCount}}</text> 次</text>
</view>
</view>
<view class="sub-stats">
<view class="sub-info">
<text class="phone">{{phoneVisible ? customerPhoneFull : customerPhone}}</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>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canPrev ? '' : 'disabled'}}" bindtap="onPrevMonth">
<t-icon name="chevron-left" size="32rpx" color="{{canPrev ? '#777777' : '#dcdcdc'}}" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canNext ? '' : 'disabled'}}" bindtap="onNextMonth">
<t-icon name="chevron-right" size="32rpx" color="{{canNext ? '#777777' : '#dcdcdc'}}" />
</view>
</view>
<!-- 月度统计概览 -->
<view class="month-summary">
<view class="summary-item">
<text class="summary-label">本月服务</text>
<text class="summary-value">{{monthCount}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">服务时长</text>
<text class="summary-value value-primary">{{monthHours}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">关系指数</text>
<text class="summary-value value-warning">{{monthRelation}}</text>
</view>
</view>
<!-- 记录列表service-record-card 组件)-->
<view class="records-container">
<!-- 无当月记录 -->
<view class="no-month-data" wx:if="{{records.length === 0}}">
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
<text class="no-month-text">本月暂无服务记录</text>
</view>
<service-record-card
wx:for="{{records}}"
wx:key="id"
time="{{item.date}}"
course-label="{{item.type}}"
type-class="{{item.typeClass}}"
type="{{item.recordType}}"
table-no="{{item.table}}"
hours="{{item.duration}}"
hours-raw="{{item.durationRaw}}"
drinks="{{item.drinks}}"
income="{{item.income}}"
is-estimate="{{item.isEstimate}}"
/>
<!-- 底部提示 -->
<view class="list-footer" wx:if="{{records.length > 0}}">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- 加载更多 -->
<view class="loading-more" wx:if="{{loadingMore}}">
<t-loading theme="circular" size="40rpx" />
</view>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button customerId="{{customerId}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,285 @@
/* pages/customer-service-records/customer-service-records.wxss */
page {
background-color: #f3f3f3;
}
/* ========== 页面状态 ========== */
.page-loading,
.page-empty,
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.error-text {
font-size: 26rpx;
color: var(--color-error, #e34d59);
}
.retry-btn {
margin-top: 8rpx;
padding: 16rpx 48rpx;
background: var(--color-primary, #0052d9);
color: #ffffff;
font-size: 28rpx;
border-radius: 22rpx;
}
.retry-btn--hover {
opacity: 0.8;
}
/* ========== Banner与 task-detail 一致)========== */
.banner-area {
position: relative;
height: 202rpx;
overflow: hidden;
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
}
.banner-bg-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 270%;
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 44rpx 0 36rpx 44rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.customer-info {
display: flex;
align-items: center;
gap: 28rpx;
padding-top: 8rpx;
}
.avatar-box {
width: 116rpx;
height: 116rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.20);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-text {
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.info-right {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 30rpx;
margin-bottom: 14rpx;
flex-wrap: nowrap;
}
.name-badges {
display: flex;
align-items: center;
gap: 20rpx;
flex-shrink: 0;
font-weight: 500;
}
.name-badge {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.18);
padding: 4rpx 20rpx;
border-radius: 20rpx;
line-height: 1.4;
}
.badge-highlight {
color: #ffffff;
font-weight: 700;
font-size: 20rpx;
}
.customer-name {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.customer-phone {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.55);
line-height: 1.4;
}
.sub-stats {
display: flex;
align-items: center;
gap: 32rpx;
}
.sub-stat {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.70);
}
.stat-highlight {
color: #ffffff;
font-weight: 700;
font-size: 26rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 22rpx;
margin-bottom: 10rpx;
}
.phone {
font-size: 26rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.70);
}
.phone-toggle-btn {
padding: 4rpx 14rpx;
background: rgba(255, 255, 255, 0.20);
border-radius: 8rpx;
display: flex;
align-items: center;
}
.phone-toggle-text {
font-size: 22rpx;
line-height: 32rpx;
color: rgba(255, 255, 255, 0.90);
}
.phone-toggle-btn--hover {
opacity: 0.7;
}
/* ========== 月份切换 ========== */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
background: #ffffff;
padding: 24rpx 32rpx;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn.disabled {
opacity: 0.3;
}
.month-label {
font-size: 26rpx;
font-weight: 600;
color: #242424;
}
/* ========== 月度统计 ========== */
.month-summary {
display: flex;
align-items: flex-start;
background: #ffffff;
padding: 24rpx 0;
border-bottom: 2rpx solid #eeeeee;
}
.summary-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.summary-label {
font-size: 20rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.summary-value {
font-size: 34rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
line-height: 44rpx;
}
.value-primary { color: #0052d9; }
.value-warning { color: #ed7b2f; }
.summary-divider {
width: 2rpx;
height: 64rpx;
background: #eeeeee;
margin-top: 4rpx;
}
/* ========== 记录列表 ========== */
.records-container {
padding: 24rpx 30rpx;
padding-bottom: 100rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 无当月数据 */
.no-month-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 20rpx;
}
.no-month-text {
font-size: 26rpx;
color: #a6a6a6;
}
/* 底部提示 */
.list-footer {
text-align: center;
padding: 20rpx 0 8rpx;
}
.footer-text {
font-size: 20rpx;
color: #c5c5c5;
}
/* 加载更多 */
.loading-more {
display: flex;
justify-content: center;
padding: 24rpx 0;
}

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "开发调试面板",
"usingComponents": {}
}

View File

@@ -0,0 +1,175 @@
/**
* 开发调试面板页面
*
* 功能:
* - 展示当前用户上下文(角色、权限、绑定、门店)
* - 一键切换角色(后端真实修改 user_site_roles + 重签 token
* - 一键切换用户状态(后端真实修改 users.status + 重签 token
* - 页面跳转列表(点击跳转到任意已注册页面)
*/
import { request } from "../../utils/request"
// 页面列表分三段:正在迁移、已完成、未完成
const MIGRATING_PAGES = [
{ path: "pages/my-profile/my-profile", name: "个人中心" },
{ path: "pages/customer-service-records/customer-service-records", name: "客户服务记录" },
{ path: "pages/chat/chat", name: "AI 对话" },
{ path: "pages/chat-history/chat-history", name: "对话历史" },
]
const DONE_PAGES = [
{ path: "pages/performance-records/performance-records", name: "业绩明细" },
{ path: "pages/coach-detail/coach-detail", name: "助教详情" },
{ path: "pages/customer-detail/customer-detail", name: "客户详情" },
{ path: "pages/performance/performance", name: "业绩总览" },
{ path: "pages/task-detail/task-detail", name: "任务详情" },
{ path: "pages/no-permission/no-permission", name: "无权限" },
{ path: "pages/login/login", name: "登录" },
{ path: "pages/apply/apply", name: "申请" },
{ path: "pages/reviewing/reviewing", name: "审核中" },
{ path: "pages/board-coach/board-coach", name: "助教看板" },
{ path: "pages/board-customer/board-customer", name: "客户看板" },
{ path: "pages/board-finance/board-finance", name: "财务看板" },
{ path: "pages/task-list/task-list", name: "任务列表(新版)" },
{ path: "pages/notes/notes", name: "备忘录" },
]
const TODO_PAGES: typeof MIGRATING_PAGES = []
const ROLE_LIST = [
{ code: "coach", name: "助教" },
{ code: "staff", name: "员工" },
{ code: "site_admin", name: "店铺管理员" },
{ code: "tenant_admin", name: "租户管理员" },
]
const STATUS_LIST = ["new", "pending", "approved", "rejected", "disabled"]
Page({
data: {
ctx: null as any,
loading: true,
pages: [] as typeof TODO_PAGES,
migratingPages: MIGRATING_PAGES,
donePages: DONE_PAGES,
todoPages: TODO_PAGES,
roles: ROLE_LIST,
statuses: STATUS_LIST,
currentRole: "",
rolesText: "-",
permissionsText: "-",
bindingText: "-",
message: "",
messageType: "",
},
onShow() {
this.loadContext()
},
/** 加载当前用户调试上下文 */
async loadContext() {
// 没有 token 时不发请求,避免 401 → 刷新 → 跳转的无限循环
const token = wx.getStorageSync("token")
if (!token) {
this.setData({ loading: false })
this.showMsg("未登录,请先通过 dev-login 获取 token", "error")
return
}
this.setData({ loading: true, message: "" })
try {
const ctx = await request({ url: "/api/xcx/dev-context", method: "GET" })
const rolesText = ctx.roles?.length ? ctx.roles.join(", ") : "-"
const permissionsText = ctx.permissions?.length ? ctx.permissions.join(", ") : "-"
let bindingText = "-"
if (ctx.binding) {
const b = ctx.binding
bindingText = `${b.binding_type} (助教:${b.assistant_id || '-'} 员工:${b.staff_id || '-'})`
}
// 当前角色取第一个(通常只有一个)
const currentRole = ctx.roles?.length ? ctx.roles[0] : ""
this.setData({ ctx, rolesText, permissionsText, bindingText, currentRole, loading: false })
} catch (err: any) {
this.setData({ loading: false })
// 401 说明 token 无效或是受限令牌,不触发重试
const detail = err?.data?.detail || "网络错误"
this.showMsg("获取上下文失败: " + detail, "error")
}
},
/** 切换角色 */
async switchRole(e: any) {
const code = e.currentTarget.dataset.code
if (code === this.data.currentRole) return
wx.showLoading({ title: "切换中..." })
try {
const res = await request({
url: "/api/xcx/dev-switch-role",
method: "POST",
data: { role_code: code },
})
// 保存新 token
this.saveTokens(res)
this.showMsg(`已切换为 ${code}`, "success")
// 重新加载上下文
this.loadContext()
} catch (err: any) {
this.showMsg("切换角色失败: " + (err?.data?.detail || "网络错误"), "error")
} finally {
wx.hideLoading()
}
},
/** 切换用户状态 */
async switchStatus(e: any) {
const status = e.currentTarget.dataset.status
if (status === this.data.ctx?.status) return
wx.showLoading({ title: "切换中..." })
try {
const res = await request({
url: "/api/xcx/dev-switch-status",
method: "POST",
data: { status },
})
// 保存新 token
this.saveTokens(res)
this.showMsg(`状态已切换为 ${status}`, "success")
this.loadContext()
} catch (err: any) {
this.showMsg("切换状态失败: " + (err?.data?.detail || "网络错误"), "error")
} finally {
wx.hideLoading()
}
},
/** 跳转到指定页面 */
goPage(e: any) {
const url = "/" + e.currentTarget.dataset.url
// 使用 reLaunch 确保能跳转到任意页面(包括 tabBar 页面)
wx.reLaunch({ url })
},
/** 保存后端返回的新 token */
saveTokens(res: any) {
if (res.access_token && res.refresh_token) {
const app = getApp<IAppOption>()
app.globalData.token = res.access_token
app.globalData.refreshToken = res.refresh_token
wx.setStorageSync("token", res.access_token)
wx.setStorageSync("refreshToken", res.refresh_token)
if (res.user_id) {
wx.setStorageSync("userId", res.user_id)
}
if (res.user_status) {
wx.setStorageSync("userStatus", res.user_status)
}
}
},
/** 显示操作提示 */
showMsg(msg: string, type: "success" | "error") {
this.setData({ message: msg, messageType: type })
setTimeout(() => this.setData({ message: "" }), 3000)
},
})

View File

@@ -0,0 +1,109 @@
<!--
开发调试面板 — 页面跳转、角色切换、状态切换、绑定切换、上下文展示
-->
<view class="container">
<!-- 当前上下文信息 -->
<view class="section">
<view class="section-title">当前上下文</view>
<view class="info-card" wx:if="{{ctx}}">
<view class="info-row"><text class="label">user_id</text><text class="value">{{ctx.user_id}}</text></view>
<view class="info-row"><text class="label">openid</text><text class="value ellipsis">{{ctx.openid || '-'}}</text></view>
<view class="info-row"><text class="label">状态</text><text class="value tag tag-{{ctx.status}}">{{ctx.status}}</text></view>
<view class="info-row"><text class="label">昵称</text><text class="value">{{ctx.nickname || '-'}}</text></view>
<view class="info-row"><text class="label">门店</text><text class="value">{{ctx.site_name || '-'}} ({{ctx.site_id || '-'}})</text></view>
<view class="info-row"><text class="label">角色</text><text class="value">{{rolesText}}</text></view>
<view class="info-row"><text class="label">权限</text><text class="value ellipsis">{{permissionsText}}</text></view>
<view class="info-row"><text class="label">绑定</text><text class="value">{{bindingText}}</text></view>
</view>
<view class="info-card" wx:else>
<text class="hint">{{loading ? '加载中...' : '未登录或无法获取上下文'}}</text>
</view>
</view>
<!-- 角色切换 -->
<view class="section">
<view class="section-title">角色切换</view>
<view class="btn-group">
<view
wx:for="{{roles}}"
wx:key="code"
class="btn {{currentRole === item.code ? 'btn-active' : ''}}"
bindtap="switchRole"
data-code="{{item.code}}"
>{{item.name}}</view>
</view>
</view>
<!-- 用户状态切换 -->
<view class="section">
<view class="section-title">用户状态切换</view>
<view class="btn-group">
<view
wx:for="{{statuses}}"
wx:key="*this"
class="btn {{ctx.status === item ? 'btn-active' : ''}}"
bindtap="switchStatus"
data-status="{{item}}"
>{{item}}</view>
</view>
</view>
<!-- 页面跳转 -->
<view class="section">
<view class="section-title">🔧 正在迁移</view>
<view class="page-list">
<view
wx:for="{{migratingPages}}"
wx:key="path"
class="page-item page-item--migrating"
bindtap="goPage"
data-url="{{item.path}}"
>
<text class="page-name">{{item.name}}</text>
<text class="page-path">/{{item.path}}</text>
</view>
<view class="page-item page-item--empty" wx:if="{{migratingPages.length === 0}}">
<text class="hint">暂无</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">✅ 已完成</view>
<view class="page-list">
<view
wx:for="{{donePages}}"
wx:key="path"
class="page-item page-item--done"
bindtap="goPage"
data-url="{{item.path}}"
>
<text class="page-name">{{item.name}}</text>
<text class="page-path">/{{item.path}}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">⏳ 未完成</view>
<view class="page-list">
<view
wx:for="{{todoPages}}"
wx:key="path"
class="page-item page-item--todo"
bindtap="goPage"
data-url="{{item.path}}"
>
<text class="page-name">{{item.name}}</text>
<text class="page-path">/{{item.path}}</text>
</view>
</view>
</view>
<!-- 操作提示 -->
<view class="section" wx:if="{{message}}">
<view class="message {{messageType}}">{{message}}</view>
</view>
</view>

View File

@@ -0,0 +1,171 @@
.container {
padding: 24rpx;
background: #f5f5f5;
min-height: 100vh;
}
.section {
margin-bottom: 32rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
padding-left: 8rpx;
border-left: 6rpx solid #1890ff;
}
.info-card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.info-row {
display: flex;
align-items: center;
padding: 10rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-size: 24rpx;
color: #999;
width: 120rpx;
flex-shrink: 0;
}
.value {
font-size: 24rpx;
color: #333;
flex: 1;
word-break: break-all;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 480rpx;
}
.hint {
font-size: 26rpx;
color: #999;
text-align: center;
padding: 20rpx 0;
}
/* 标签样式 */
.tag {
display: inline-block;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
}
.tag-approved { background: #e6f7e6; color: #52c41a; }
.tag-pending { background: #fff7e6; color: #faad14; }
.tag-new { background: #e6f7ff; color: #1890ff; }
.tag-rejected { background: #fff1f0; color: #ff4d4f; }
.tag-disabled { background: #f5f5f5; color: #999; }
/* 按钮组 */
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.btn {
padding: 16rpx 28rpx;
background: #fff;
border: 2rpx solid #d9d9d9;
border-radius: 12rpx;
font-size: 26rpx;
color: #333;
text-align: center;
}
.btn-active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
/* 页面列表 */
.page-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.page-item {
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.page-item:last-child {
border-bottom: none;
}
.page-item:active {
background: #f0f5ff;
}
.page-item--migrating {
border-left: 6rpx solid #faad14;
}
.page-item--done {
border-left: 6rpx solid #52c41a;
}
.page-item--todo {
border-left: 6rpx solid #d9d9d9;
}
.page-item--empty {
text-align: center;
padding: 24rpx;
}
.page-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
display: block;
}
.page-path {
font-size: 22rpx;
color: #999;
font-family: monospace;
display: block;
margin-top: 4rpx;
}
/* 消息提示 */
.message {
padding: 16rpx 24rpx;
border-radius: 12rpx;
font-size: 26rpx;
text-align: center;
}
.success {
background: #e6f7e6;
color: #52c41a;
}
.error {
background: #fff1f0;
color: #ff4d4f;
}

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,63 @@
// DEMO 演示版 — 点击登录模拟成功,写入 mock 用户信息后跳转主页
Page({
data: {
agreed: false,
loading: false,
/** 系统状态栏高度px用于自定义导航栏顶部偏移 */
statusBarHeight: 20,
},
onLoad() {
const windowInfo = wx.getWindowInfo()
this.setData({ statusBarHeight: windowInfo.statusBarHeight || 20 })
},
onAgreeChange() {
this.setData({ agreed: !this.data.agreed })
},
async onLogin() {
if (!this.data.agreed) {
wx.showToast({ title: "请先同意用户协议", icon: "none" })
return
}
if (this.data.loading) return
this.setData({ loading: true })
// DEMO模拟登录成功写入 mock 用户信息
const mockToken = "demo_token_" + Date.now()
const mockUser = {
userId: "demo_user_001",
status: "approved",
nickname: "小燕",
role: "coach",
coachLevel: "senior",
storeName: "朗朗桌球·旗舰店",
avatar: "",
}
const app = getApp<IAppOption>()
app.globalData.token = mockToken
app.globalData.refreshToken = "demo_refresh_token"
app.globalData.authUser = mockUser
wx.setStorageSync("token", mockToken)
wx.setStorageSync("refreshToken", "demo_refresh_token")
wx.setStorageSync("userId", mockUser.userId)
wx.setStorageSync("userStatus", mockUser.status)
wx.setStorageSync("userRole", mockUser.role)
wx.setStorageSync("storeName", mockUser.storeName)
wx.setStorageSync("coachLevel", mockUser.coachLevel)
wx.setStorageSync("avatar", mockUser.avatar)
wx.showToast({ title: "登录成功", icon: "success" })
// 500ms 后跳转主页
setTimeout(() => {
this.setData({ loading: false })
wx.reLaunch({ url: "/pages/task-list/task-list" })
}, 500)
},
})

View File

@@ -0,0 +1,77 @@
<!--pages/login/login.wxml — 忠于 H5 原型,使用 TDesign 组件 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 动态背景层 -->
<image class="login-bg-layer" src="/assets/images/login-bg-animated.svg" mode="scaleToFill" />
<!-- 装饰元素:模拟 blur 圆形 -->
<view class="deco-circle deco-circle--1"></view>
<view class="deco-circle deco-circle--2"></view>
<view class="deco-circle deco-circle--3"></view>
<!-- 顶部区域 - Logo 和名称 -->
<view class="hero">
<!-- Logo浮动动画 -->
<view class="logo-wrap float-animation">
<view class="logo-box">
<image class="logo-icon" src="/assets/icons/logo-billiard.svg" mode="aspectFit" />
</view>
<!-- 装饰点 -->
<view class="logo-dot logo-dot--tr"></view>
<view class="logo-dot logo-dot--bl"></view>
</view>
<!-- 应用名称 -->
<text class="app-name">球房运营助手</text>
<text class="app-desc">为台球厅提升运营效率的内部管理工具</text>
<!-- 功能亮点 -->
<view class="features">
<view class="feature-item">
<view class="feature-icon-svg feature-icon-svg--task">
<image class="feature-svg-img" src="/assets/icons/feature-task.svg" mode="aspectFit" />
</view>
<text class="feature-text">任务管理</text>
</view>
<view class="feature-item">
<view class="feature-icon-svg feature-icon-svg--board">
<image class="feature-svg-img" src="/assets/icons/feature-board.svg" mode="aspectFit" />
</view>
<text class="feature-text">数据看板</text>
</view>
<view class="feature-item">
<view class="feature-icon-svg feature-icon-svg--ai">
<image class="feature-svg-img" src="/assets/icons/feature-ai.svg" mode="aspectFit" />
</view>
<text class="feature-text">智能助手</text>
</view>
</view>
</view>
<!-- 底部区域 - 登录按钮和协议 -->
<view class="bottom-area">
<!-- 微信登录按钮 -->
<view class="login-btn-wrap {{(!agreed || loading) ? 'login-btn--disabled' : 'login-btn--active'}}" bindtap="onLogin">
<image wx:if="{{!loading}}" class="wechat-icon" src="/assets/icons/icon-wechat.svg" mode="aspectFit" />
<t-loading wx:if="{{loading}}" theme="circular" size="32rpx" color="#fff" />
<text class="login-btn-text">使用微信登录</text>
</view>
<!-- 协议勾选 -->
<view class="agreement" bindtap="onAgreeChange">
<view class="checkbox {{agreed ? 'checkbox--checked' : ''}}">
<t-icon wx:if="{{agreed}}" name="check" size="18rpx" color="#fff" />
</view>
<view class="agreement-text-wrap">
<text class="agreement-text">我已阅读并同意</text>
<text class="link">《用户协议》</text>
<text class="agreement-text">和</text>
<text class="link">《隐私政策》</text>
</view>
</view>
<!-- 底部说明 -->
<text class="footer-tip">仅限球房内部员工使用</text>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,337 @@
/* pages/login/login.wxss — 忠于 H5 原型TDesign 组件定制 */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(160deg, #dbeafe 0%, #eff6ff 45%, #e0f2fe 100%);
position: relative;
overflow: hidden;
box-sizing: border-box;
/* padding-top 由 JS statusBarHeight 动态设置box-sizing 确保 padding 包含在 100vh 内 */
}
/* ---- 动态背景层 ---- */
.login-bg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
/* ---- 装饰圆形 ---- */
.deco-circle {
position: absolute;
border-radius: 50%;
z-index: 0;
}
.deco-circle--1 {
width: 176rpx;
height: 176rpx;
background: radial-gradient(circle, var(--color-primary-shadow-minimal) 0%, transparent 70%);
top: 140rpx;
left: 56rpx;
opacity: 0.8;
animation: pulse-soft 3s ease-in-out infinite;
}
.deco-circle--2 {
width: 210rpx;
height: 210rpx;
background: radial-gradient(circle, rgba(103, 232, 249, 0.12) 0%, transparent 70%);
top: 280rpx;
right: 42rpx;
opacity: 0.8;
animation: pulse-soft 3s ease-in-out infinite 1s;
}
.deco-circle--3 {
width: 140rpx;
height: 140rpx;
background: radial-gradient(circle, rgba(96, 165, 250, 0.12) 0%, transparent 70%);
bottom: 280rpx;
left: 84rpx;
opacity: 0.8;
animation: pulse-soft 3s ease-in-out infinite 0.5s;
}
/* ---- Hero 区域 ---- */
.hero {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 56rpx;
position: relative;
z-index: 1;
}
/* ---- Logo ---- */
.logo-wrap {
position: relative;
margin-bottom: 42rpx;
}
.logo-box {
width: 168rpx;
height: 168rpx;
border-radius: 42rpx;
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 14rpx 42rpx var(--color-primary-shadow);
}
.logo-icon {
width: 98rpx;
height: 98rpx;
}
.logo-dot {
position: absolute;
border-radius: 50%;
}
.logo-dot--tr {
width: 28rpx;
height: 28rpx;
background: var(--primary-dot-cyan);
top: -8rpx;
right: -8rpx;
box-shadow: 0 4rpx 10rpx var(--primary-dot-cyan-shadow);
}
.logo-dot--bl {
width: 22rpx;
height: 22rpx;
background: var(--primary-dot-blue);
bottom: -14rpx;
left: -14rpx;
box-shadow: 0 4rpx 10rpx var(--primary-dot-blue-shadow);
}
/* ---- 应用名称 ---- */
.app-name {
font-size: 42rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 14rpx;
}
.app-desc {
font-size: 24rpx;
color: var(--text-secondary);
text-align: center;
line-height: 1.625;
margin-bottom: 56rpx;
}
/* ---- 功能亮点 ---- */
.features {
width: 100%;
display: flex;
justify-content: space-around;
background: rgba(255, 255, 255, 0.62);
border-radius: 32rpx;
padding: 40rpx 18rpx 36rpx;
margin-bottom: 42rpx;
box-shadow: 0 4rpx 32rpx rgba(59, 130, 246, 0.10), 0 1rpx 4rpx rgba(59, 130, 246, 0.07);
border: 1.5rpx solid rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
}
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.feature-icon {
width: 70rpx;
height: 70rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.feature-icon--primary { background: var(--color-primary-shadow-minimal); }
.feature-icon--success { background: var(--color-success-shadow-minimal); }
.feature-icon--warning { background: var(--color-warning-shadow-minimal); }
/* ---- 华丽 SVG 功能图标 ---- */
.feature-icon-svg {
width: 100rpx;
height: 100rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: visible;
}
.feature-icon-svg--task {
background: linear-gradient(145deg, #e8f0fe 0%, #c7d9ff 100%);
box-shadow: 0 8rpx 24rpx rgba(79, 142, 247, 0.28), 0 2rpx 6rpx rgba(26, 95, 216, 0.15);
animation: icon-float 4s ease-in-out infinite;
}
.feature-icon-svg--board {
background: linear-gradient(145deg, #d1fae5 0%, #a7f3d0 100%);
box-shadow: 0 8rpx 24rpx rgba(0, 200, 150, 0.28), 0 2rpx 6rpx rgba(0, 140, 106, 0.15);
animation: icon-float 4s ease-in-out infinite 1.3s;
}
.feature-icon-svg--ai {
background: linear-gradient(145deg, #fef3e2 0%, #fde0b8 100%);
box-shadow: 0 8rpx 24rpx rgba(255, 140, 66, 0.28), 0 2rpx 6rpx rgba(224, 90, 0, 0.15);
animation: icon-float 4s ease-in-out infinite 2.6s;
}
.feature-svg-img {
width: 72rpx;
height: 72rpx;
}
@keyframes icon-float {
0%, 100% { transform: translateY(0) scale(1); }
40% { transform: translateY(-8rpx) scale(1.04); }
60% { transform: translateY(-6rpx) scale(1.03); }
}
.feature-text {
font-size: 24rpx;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.5rpx;
}
/* ---- 底部操作区 ---- */
.bottom-area {
width: 100%;
padding: 0 56rpx 70rpx;
position: relative;
z-index: 1;
box-sizing: border-box;
}
/* TDesign Button 替换为原生 view 按钮,避免 TDesign 默认样式干扰 */
.login-btn-wrap {
width: 100%;
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
font-weight: 500;
font-size: 28rpx;
transition: all 0.2s;
}
.wechat-icon {
width: 36rpx;
height: 36rpx;
}
.login-btn-text {
color: var(--color-white);
font-size: 28rpx;
font-weight: 500;
}
/* 未勾选协议 → 灰色禁用态(忠于原型 btn-disabled */
.login-btn--disabled {
background: var(--color-gray-4);
}
.login-btn--disabled .login-btn-text {
color: var(--color-white);
}
/* 勾选协议 → 蓝色渐变(忠于原型 from-primary to-blue-500 */
.login-btn--active {
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
box-shadow: 0 10rpx 28rpx var(--color-primary-shadow);
}
/* ---- 协议勾选 ---- */
.agreement {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-top: 36rpx;
}
.checkbox {
width: 28rpx;
height: 28rpx;
min-width: 28rpx;
min-height: 28rpx;
border: 2rpx solid var(--color-gray-4);
border-radius: 6rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 4rpx;
margin-right: 10rpx;
background: transparent;
flex-shrink: 0;
transition: all 0.2s;
}
.checkbox--checked {
background: var(--color-primary);
border-color: var(--color-primary);
}
.agreement-text-wrap {
flex: 1;
font-size: 22rpx;
line-height: 1.625;
}
.agreement-text {
font-size: 22rpx;
color: var(--text-secondary);
line-height: 1.625;
}
.link {
color: var(--color-primary);
font-weight: 500;
font-size: 22rpx;
line-height: 1.625;
}
/* ---- 底部提示 ---- */
.footer-tip {
display: block;
width: 100%;
text-align: center;
font-size: 22rpx;
color: var(--text-disabled);
margin-top: 42rpx;
}
/* ---- 动画 ---- */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-14rpx); }
}
.float-animation {
animation: float 4s ease-in-out infinite;
}
@keyframes pulse-soft {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "个人中心",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,38 @@
import { mockUserProfile } from '../../utils/mock-data'
import { getMenuRoute, navigateTo } from '../../utils/router'
// TODO: 联调时替换为真实 API 获取用户信息
Page({
data: {
userInfo: mockUserProfile,
},
onShow() {
// 同步 custom-tab-bar 选中态
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'my' })
// TODO: 联调时在此刷新用户信息
},
onMenuTap(e: WechatMiniprogram.TouchEvent) {
const key = e.currentTarget.dataset.key as string
const route = getMenuRoute(key)
if (route) {
navigateTo(route)
}
},
onLogout() {
wx.showModal({
title: '确认退出',
content: '确认退出当前账号吗?',
confirmColor: '#e34d59',
success(res) {
if (res.confirm) {
wx.clearStorageSync()
wx.reLaunch({ url: '/pages/login/login' })
}
},
})
},
})

View File

@@ -0,0 +1,66 @@
<!-- 我的页面 -->
<view class="page-my-profile">
<!-- 用户信息区域 -->
<view class="user-card">
<view class="avatar-wrap">
<image class="avatar" src="{{userInfo.avatar}}" mode="aspectFill" />
</view>
<view class="user-info">
<view class="name-row">
<text class="name">{{userInfo.name}}</text>
<text class="role-tag">{{userInfo.role}}</text>
</view>
<text class="store-name">{{userInfo.storeName}}</text>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-list">
<!-- 备注记录 -->
<view class="menu-item" hover-class="menu-item--hover" bind:tap="onMenuTap" data-key="notes">
<view class="menu-left">
<view class="menu-icon icon-notes">
<image src="/assets/icons/menu-notes.svg" class="menu-icon-img" mode="aspectFit" />
</view>
<text class="menu-text">备注记录</text>
</view>
<view class="menu-chevron">
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
</view>
</view>
<!-- 助手对话记录 -->
<view class="menu-item" hover-class="menu-item--hover" bind:tap="onMenuTap" data-key="chat-history">
<view class="menu-left">
<view class="menu-icon icon-chat">
<image src="/assets/icons/menu-chat.svg" class="menu-icon-img" mode="aspectFit" />
</view>
<text class="menu-text">助手对话记录</text>
</view>
<view class="menu-chevron">
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
</view>
</view>
<!-- 退出账号 -->
<view class="menu-item menu-item--last" hover-class="menu-item--hover" bind:tap="onLogout">
<view class="menu-left">
<view class="menu-icon icon-logout">
<image src="/assets/icons/menu-logout.svg" class="menu-icon-img" mode="aspectFit" />
</view>
<text class="menu-text">退出账号</text>
</view>
<view class="menu-chevron">
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx -->
<ai-float-button visible="{{true}}" />
<dev-fab wx:if="{{false}}" />

View File

@@ -0,0 +1,135 @@
.page-my-profile {
min-height: 100vh;
background: var(--color-bg-page, #f3f3f3);
}
/* 用户信息卡片 */
.user-card {
display: flex;
align-items: center;
gap: 28rpx;
padding: 44rpx 40rpx;
background: #fff;
}
.avatar-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.10);
flex-shrink: 0;
border: 4rpx solid #fff;
background: #f3f3f3;
}
.avatar {
width: 100%;
height: 100%;
}
.user-info {
flex: 1;
overflow: hidden;
}
.name-row {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 10rpx;
}
.name {
font-size: 36rpx;
font-weight: 700;
color: var(--color-text-primary, #242424);
line-height: 1.4;
}
.role-tag {
padding: 4rpx 16rpx;
background: rgba(0, 82, 217, 0.10);
color: #0052d9;
font-size: 22rpx;
border-radius: 10rpx;
font-weight: 500;
line-height: 30rpx;
}
.store-name {
font-size: 24rpx;
color: var(--color-text-placeholder, #8b8b8b);
line-height: 1.5;
}
/* 菜单列表 */
.menu-list {
margin-top: 24rpx;
background: #fff;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 36rpx;
background: #fff;
border-bottom: 2rpx solid #f3f3f3;
}
.menu-item--last {
border-bottom: none;
}
.menu-item--hover {
background: #f7f7f7;
}
.menu-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.menu-icon {
width: 68rpx;
height: 68rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-icon-img {
width: 48rpx;
height: 48rpx;
}
.icon-notes {
background: rgba(0, 82, 217, 0.08);
}
.icon-chat {
background: rgba(0, 168, 112, 0.08);
}
.icon-logout {
background: rgba(227, 77, 89, 0.08);
}
.menu-text {
font-size: 28rpx;
color: var(--color-text-primary, #242424);
line-height: 1.5;
font-weight: 500;
}
.menu-chevron {
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
}

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "无权限",
"navigationStyle": "custom",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,74 @@
// pages/no-permission/no-permission.ts
// 无权限页面 — 账号已禁用或无访问权限时展示
// onShow 时查询最新状态,状态变化时自动跳转
import { request } from "../../utils/request"
Page({
data: {
statusBarHeight: 0,
},
onLoad() {
const { statusBarHeight = 0 } = wx.getWindowInfo()
this.setData({ statusBarHeight })
},
onShow() {
this._checkStatus()
},
/** 查询最新用户状态,非 disabled 时自动跳转 */
async _checkStatus() {
const token = wx.getStorageSync("token")
if (!token) {
wx.reLaunch({ url: "/pages/login/login" })
return
}
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
status: data.status,
nickname: data.nickname,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.status)
switch (data.status) {
case "disabled":
case "rejected":
break // 留在当前页
case "approved":
wx.reLaunch({ url: "/pages/mvp/mvp" })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
break
case "new":
wx.reLaunch({ url: "/pages/apply/apply" })
break
}
} catch {
// 网络错误不阻塞
}
},
/** 更换登录账号:清除凭证后跳转登录页 */
onSwitchAccount() {
const app = getApp<IAppOption>()
app.globalData.token = undefined
app.globalData.refreshToken = undefined
app.globalData.authUser = undefined
wx.removeStorageSync("token")
wx.removeStorageSync("refreshToken")
wx.removeStorageSync("userId")
wx.removeStorageSync("userStatus")
wx.reLaunch({ url: "/pages/login/login" })
},
})

View File

@@ -0,0 +1,67 @@
<!-- pages/no-permission/no-permission.wxml — 按 H5 原型结构迁移 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 十字纹背景图案H5 bg-patternerror 色) -->
<view class="bg-pattern"></view>
<!-- 顶部渐变装饰(红色主题) -->
<view class="top-gradient"></view>
<!-- 主体内容 -->
<view class="content">
<!-- 图标区域 -->
<view class="icon-area">
<!-- 背景光晕 -->
<view class="icon-glow"></view>
<!-- 主图标 -->
<view class="icon-box">
<image class="icon-main-img" src="/assets/icons/icon-forbidden.svg" mode="aspectFit" />
</view>
<!-- 装饰点 -->
<view class="icon-dot dot-1"></view>
<view class="icon-dot dot-2"></view>
</view>
<!-- 标题区域 -->
<view class="title-area">
<text class="main-title">无访问权限</text>
<text class="sub-title">很抱歉,您的访问申请未通过审核,或当前账号无访问权限</text>
</view>
<!-- 原因说明卡片 -->
<view class="reason-card">
<view class="reason-header">
<view class="reason-icon-box">
<t-icon name="error-circle-filled" size="35rpx" color="#e34d59" />
</view>
<view class="reason-header-text">
<text class="reason-title">可能的原因</text>
<view class="reason-list">
<text class="reason-item">• 申请信息不完整或不符合要求</text>
<text class="reason-item">• 非本店授权员工账号</text>
<text class="reason-item">• 账号权限已被管理员收回</text>
</view>
</view>
</view>
<!-- 联系管理员 -->
<view class="reason-footer">
<text class="reason-footer-label">请联系管理员</text>
<text class="reason-footer-value">厉超</text>
</view>
</view>
<!-- 帮助提示 -->
<view class="contact-hint">
<t-icon name="help-circle-filled" size="28rpx" color="#a6a6a6" />
<text class="contact-text">如有疑问,请联系管理员重新申请</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-area">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
</view>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,241 @@
/* pages/no-permission/no-permission.wxss — 原 H5 值 × 0.875 统一缩放 */
.page {
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
background-color: #f8fafc;
position: relative;
}
.bg-pattern {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
repeating-linear-gradient(0deg, transparent, transparent 58rpx, rgba(227,77,89,0.03) 58rpx, rgba(227,77,89,0.03) 62rpx, transparent 62rpx, transparent 120rpx),
repeating-linear-gradient(90deg, transparent, transparent 58rpx, rgba(227,77,89,0.03) 58rpx, rgba(227,77,89,0.03) 62rpx, transparent 62rpx, transparent 120rpx);
z-index: 0;
pointer-events: none;
}
/* 512 × 0.875 = 448 */
.top-gradient {
position: absolute;
top: 0; left: 0; right: 0;
height: 448rpx;
background: linear-gradient(to bottom, rgba(227,77,89,0.10), transparent);
z-index: 0;
}
/* padding 64 × 0.875 = 56 */
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 56rpx;
position: relative;
z-index: 1;
box-sizing: border-box;
}
/* mb 64 × 0.875 = 56 */
.icon-area {
position: relative;
margin-bottom: 56rpx;
}
/* 320 × 0.875 = 280, offset -48 × 0.875 = -42 */
.icon-glow {
position: absolute;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
top: -42rpx;
left: -42rpx;
background: radial-gradient(circle, rgba(227,77,89,0.18) 0%, rgba(227,77,89,0.06) 50%, transparent 75%);
}
/* 224 × 0.875 = 196, radius 48 × 0.875 = 42 */
.icon-box {
position: relative;
width: 196rpx;
height: 196rpx;
border-radius: 42rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fb7185, #ef4444);
box-shadow: 0 14rpx 42rpx rgba(227,77,89,0.3);
}
/* 112 × 0.875 = 98 */
.icon-main-img {
width: 98rpx;
height: 98rpx;
}
.icon-dot { position: absolute; border-radius: 50%; }
/* 32 × 0.875 = 28, offset -16 × 0.875 = -14 */
.dot-1 {
width: 28rpx; height: 28rpx;
top: -14rpx; right: -14rpx;
background: #e34d59; opacity: 0.6;
}
/* 24 × 0.875 = 21 → 22, offset -8 × 0.875 = -7 → -8, -24 × 0.875 = -21 → -22 */
.dot-2 {
width: 22rpx; height: 22rpx;
bottom: -8rpx; left: -22rpx;
background: #fda4af; opacity: 0.6;
}
/* mb 64 × 0.875 = 56 */
.title-area {
text-align: center;
margin-bottom: 56rpx;
}
/* 48 × 0.875 = 42, mb 24 × 0.875 = 21 → 22 */
.main-title {
display: block;
font-size: 42rpx;
font-weight: 700;
color: #4b4b4b;
margin-bottom: 22rpx;
}
/* 28 × 0.875 = 24.5 → 24, max-w 640 × 0.875 = 560 */
.sub-title {
display: block;
font-size: 24rpx;
color: #8b8b8b;
line-height: 1.625;
max-width: 490rpx;
margin: 0 auto;
}
/* max-w 550 × 0.875 ≈ 482, radius 32 × 0.875 = 28, padding 40 × 0.875 = 35 → 36, mb 48 × 0.875 = 42 */
.reason-card {
width: 100%;
max-width: 482rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 36rpx;
margin-bottom: 42rpx;
box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.06);
}
/* gap/mb 32 × 0.875 = 28 */
.reason-header {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 28rpx;
margin-bottom: 28rpx;
}
/* 80 × 0.875 = 70, radius 24 × 0.875 = 21 → 22 */
.reason-icon-box {
width: 70rpx;
height: 70rpx;
min-width: 70rpx;
background: rgba(227,77,89,0.1);
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.reason-header-text {
display: flex;
flex-direction: column;
flex: 1;
}
/* 28 × 0.875 = 24, mb 8 × 0.875 = 7 → 8 */
.reason-title {
font-size: 24rpx;
font-weight: 500;
color: #242424;
margin-bottom: 8rpx;
}
/* gap 8 × 0.875 = 7 → 8 */
.reason-list {
display: flex;
flex-direction: column;
gap: 8rpx;
}
/* 24 × 0.875 = 21 → 22 */
.reason-item {
font-size: 22rpx;
color: #a6a6a6;
line-height: 1.5;
}
/* mt/pt 32 × 0.875 = 28 */
.reason-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28rpx;
padding-top: 28rpx;
border-top: 2rpx solid #f3f3f3;
}
.reason-footer-label {
font-size: 22rpx;
color: #a6a6a6;
}
.reason-footer-value {
font-size: 24rpx;
color: #242424;
font-weight: 500;
}
/* gap 16 × 0.875 = 14, mb 64 × 0.875 = 56 */
.contact-hint {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 56rpx;
}
.contact-text {
font-size: 22rpx;
color: #a6a6a6;
}
/* px 64 × 0.875 = 56, pb 96 × 0.875 = 84 */
.bottom-area {
padding: 0 56rpx 84rpx;
position: relative;
z-index: 1;
}
/* padding 28 × 0.875 = 24.5 → 24, gap 16 × 0.875 = 14, radius 24 × 0.875 = 21 → 22 */
.switch-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
padding: 24rpx 0;
background: #ffffff;
border: 2rpx solid #eeeeee;
border-radius: 22rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.03);
}
.switch-btn-text {
font-size: 24rpx;
font-weight: 500;
color: #5e5e5e;
}

View File

@@ -0,0 +1,220 @@
# Notes 页面删除功能实现说明
## 更新日期
2026-03-14
## 功能描述
为每条备注添加删除按钮,放置在日期左侧,交互和逻辑参考任务详情页的备注删除处理方式。
---
## 实现细节
### 1. WXML 结构调整
#### 原结构
```xml
<view class="note-bottom">
<text class="note-tag">{{item.tagLabel}}</text>
<text class="note-time">{{item.createdAt}}</text>
</view>
```
#### 新结构
```xml
<view class="note-bottom">
<view class="note-bottom-left">
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="20px" color="#a6a6a6" />
</view>
<text class="note-time">{{item.createdAt}}</text>
</view>
<text class="note-tag">{{item.tagLabel}}</text>
</view>
```
**关键点**
- 使用 `catchtap` 而非 `bindtap`,阻止事件冒泡
- 删除按钮放在左侧,日期紧随其后
- 标签移到右侧
- 通过 `data-id` 传递备注ID
### 2. WXSS 样式添加
```wxss
/* 底部容器布局调整 */
.note-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 左侧容器(删除按钮 + 日期)*/
.note-bottom-left {
display: flex;
align-items: center;
gap: 8px;
}
/* 删除按钮样式 */
.note-delete-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
}
.note-delete-btn--hover {
background-color: #f3f3f3;
}
```
**样式说明**
- 删除按钮尺寸32px × 32px符合最小点击区域44px的80%
- Icon尺寸20px与H5原型对齐
- Icon颜色#a6a6a6gray-6次级文字颜色
- Hover效果浅灰背景#f3f3f3
- 按钮与日期间距8px
### 3. TypeScript 逻辑实现
```typescript
/** 删除备注 */
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
const noteId = e.currentTarget.dataset.id as string
wx.showModal({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
if (res.confirm) {
const notes = this.data.notes.filter((n) => n.id !== noteId)
this.setData({ notes })
wx.showToast({ title: '已删除', icon: 'success' })
}
},
})
}
```
**逻辑说明**
-`dataset` 中获取备注ID
- 使用 `wx.showModal` 显示确认弹窗
- 确认按钮颜色:#e34d59error色警示作用
- 确认后过滤掉对应备注
- 显示成功提示
---
## 参考实现
### 任务详情页删除逻辑
位置:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
```typescript
/** 删除备注 */
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
const noteId = e.currentTarget.dataset.id as string
wx.showModal({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
if (res.confirm) {
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
this.setData({ sortedNotes: notes })
wx.showToast({ title: '已删除', icon: 'success' })
}
},
})
}
```
**差异点**
- 任务详情页使用 `sortedNotes`notes页使用 `notes`
- 其他逻辑完全一致
---
## 交互流程
1. 用户点击删除按钮
2. 显示确认弹窗:"确定要删除这条备注吗?删除后无法恢复。"
3. 用户选择:
- **确认**:删除备注,显示"已删除"提示
- **取消**:关闭弹窗,不做任何操作
---
## 视觉效果
### 布局
```
┌─────────────────────────────────────────┐
│ 备注内容文本... │
│ │
│ [🗑️] 2024-11-27 16:00 [客户:王先生] │
└─────────────────────────────────────────┘
```
### 尺寸对比
| 元素 | 尺寸 | 说明 |
|------|------|------|
| 删除按钮容器 | 32px × 32px | 圆形点击区域 |
| 删除icon | 20px | TDesign delete图标 |
| 按钮与日期间距 | 8px | gap |
| 日期字号 | 12px | text-xs |
| 标签字号 | 12px | text-xs |
---
## 颜色规范
| 元素 | 颜色 | 色值 | 说明 |
|------|------|------|------|
| 删除icon | gray-6 | #a6a6a6 | 次级文字颜色 |
| Hover背景 | gray-1 | #f3f3f3 | 浅灰背景 |
| 确认按钮 | error | #e34d59 | 警示色 |
---
## 测试清单
- [x] 删除按钮显示正确
- [x] 删除按钮位置正确(日期左侧)
- [x] 点击删除按钮显示确认弹窗
- [x] 确认删除后备注消失
- [x] 取消删除后备注保留
- [x] 删除成功显示提示
- [x] Hover效果正常
- [x] 事件不冒泡使用catchtap
- [x] 代码无linting错误
---
## 文件变更清单
1. **notes.wxml** - 添加删除按钮结构
2. **notes.wxss** - 添加删除按钮样式
3. **notes.ts** - 添加删除逻辑
4. **DELETE_FEATURE.md** - 本文档
---
## 后续优化建议
1. **API集成**当前为前端删除需要对接后端API实现真实删除
2. **撤销功能**:考虑添加"撤销删除"功能3秒内可撤销
3. **批量删除**:如果需要,可以添加批量删除功能
4. **权限控制**:根据用户角色控制删除权限
---
## 参考文档
- 桥文档:`docs/miniprogram-dev/h5-to-mp-bridge-v3.md`
- 任务详情页:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- TDesign图标https://tdesign.tencent.com/miniprogram/components/icon

View File

@@ -0,0 +1,185 @@
# Notes 页面迁移说明
## 迁移版本
- **H5 原型基准**`docs/h5_ui/pages/notes.html`
- **桥文档版本**v3.0
- **迁移日期**2026-03-14
---
## 样式调整清单
### 1. 单位转换规则
遵循桥文档 §2.3 的混合单位策略:
- **主要使用 `px`**因为notes页面的尺寸需要与H5原型1:1对齐
- **换算基准**H5 CSS px → 小程序 px412宽设备验收基准
### 2. 导航栏(.custom-nav
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| height | 44px | 44px | 标准导航栏高度 |
| padding | 0 16px | 0 16px | 左右内边距 |
| border-bottom | 1px solid | 1px solid #eeeeee | 分割线 |
**icon 尺寸调整**
- H5: `w-5 h-5` (20px)
- 小程序: `size="24px"` (考虑小程序点击区域最小44px)
### 3. 备注卡片(.note-card
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| border-radius | rounded-2xl (16px) | 16px | 圆角 |
| padding | p-4 (16px) | 16px | 内边距 |
| box-shadow | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 阴影 |
| margin-bottom | mb-3 (12px) | 12px | 卡片间距 |
### 4. 文本样式
#### 备注内容(.note-content
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| font-size | text-sm (14px) | 14px | 正文字号 |
| line-height | leading-relaxed (1.625) | 20px | 行高按附录B标准 |
| color | text-gray-13 | #242424 | 正文颜色 |
| margin-bottom | mb-3 (12px) | 12px | 下边距 |
**line-height 设置规则**附录B.2.3
- 在外层 `<view>` 的 class 上设置 `line-height`
- `<text>` 会自动继承,不需要额外设置
#### 标签(.note-tag
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| padding | px-2.5 py-1 (10px 4px) | 4px 10px | 标签内边距 |
| font-size | text-xs (12px) | 12px | 标签字号 |
| border-radius | rounded-lg (8px) | 8px | 圆角 |
| border | 1px solid | 1px solid | 边框 |
#### 时间戳(.note-time
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| font-size | text-xs (12px) | 12px | 时间字号 |
| color | text-gray-6 | #a6a6a6 | 次级文字颜色 |
### 5. 列表容器(.note-list
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| padding | p-4 (16px) | 16px | 列表外边距 |
| gap | space-y-3 (12px) | 12px | 卡片间距 |
### 6. 颜色映射附录C
| 用途 | 色值 | 说明 |
|------|------|------|
| 主品牌色 | #0052d9 | primary |
| 成功色 | #00a870 | success |
| 背景色 | #f3f3f3 | gray-1 |
| 正文色 | #242424 | gray-13 |
| 次级文字 | #a6a6a6 | gray-6 |
| 分割线 | #eeeeee | gray-2 |
### 7. 标签渐变背景
**客户标签**
```css
background: linear-gradient(135deg, #ecf2fe, #e8eeff);
color: #0052d9;
border-color: #c5d4f7;
```
**助教标签**
```css
background: linear-gradient(135deg, #e8faf0, #e0f7ea);
color: #00a870;
border-color: #b3e6d0;
```
---
## 关键调整点
### 1. 全局 line-height 设置
```wxss
page {
line-height: 1.5;
}
view {
line-height: inherit;
}
```
**原因**:微信小程序 `<text>` 组件不能直接设置 `line-height`,必须在外层 `<view>` 设置附录B.2.3
### 2. 单位统一为 px
- 原小程序版本混用 `rpx` 和 CSS 变量
- 调整为直接使用 `px`确保与H5原型1:1对齐
- 所有尺寸基于412宽设备验收基准
### 3. 移除 CSS 变量依赖
- 原:`var(--color-gray-2, #eeeeee)`
- 新:直接使用 `#eeeeee`
- 原因:确保样式独立,不依赖全局变量
### 4. Icon 尺寸调整
- 原:`size="48rpx"` (约88px)
- 新:`size="24px"` (标准导航栏icon尺寸)
- 原因与H5原型中的 `w-5 h-5` 对齐
### 5. 加载态 icon 尺寸
- 原:`size="80rpx"` (约146px)
- 新:`size="40px"` (适配小屏幕)
---
## 附录参考
### 使用的附录
1. **附录A**Spacing 与尺寸字典 v3.0
- 间距换算16px → 16pxpx模式
- 圆角8px, 16px
2. **附录B**:字体与文本字典 v3.1
- text-sm: 14px / 20px
- text-xs: 12px / 16px
- **关键规则**line-height 必须在外层 view 上设置
3. **附录C**:颜色字典 v3.0
- 项目自定义颜色表
- 透明度变体处理
4. **附录D**:布局类映射字典 v3.0
- flex 布局
- gap 间距
---
## 验收清单
- [x] 导航栏高度与H5对齐44px
- [x] 卡片圆角与H5对齐16px
- [x] 文字大小与H5对齐14px/12px
- [x] 行高设置正确在view上设置
- [x] 颜色值与H5对齐
- [x] 间距与H5对齐16px padding, 12px gap
- [x] 标签样式与H5对齐渐变背景
- [x] 阴影效果与H5对齐shadow-sm
---
## 已知限制
1. **安全区处理**:使用 `statusBarHeight` 动态计算,不使用 `env(safe-area-inset-*)`
2. **AI 悬浮按钮**bottom 值为 120px需根据实际设备调整
3. **页面滚动**:使用页面自然滚动,未使用 scroll-view
---
## 后续维护
- 若真机测试发现尺寸偏差,优先检查 line-height 设置
- 若需要调整间距参考附录A的spacing表
- 若需要调整字号参考附录B的字体表
- 颜色调整参考附录C的颜色字典

View File

@@ -0,0 +1,139 @@
# Notes 页面 H5 → 小程序 样式对比表
## 快速参考
### 导航栏
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|------|-----------|----------|-----------|
| 导航栏高度 | h-11 | 44px | 44px |
| 导航栏 padding | px-4 | 0 16px | 0 16px |
| 返回按钮大小 | w-5 h-5 | 20px | 44px (容器) |
| 返回 icon 尺寸 | — | — | 24px |
| 标题字号 | text-base | 16px | 16px |
| 标题字重 | font-medium | 500 | 500 |
| 分割线 | border-b border-gray-2 | 1px solid #eeeeee | 1px solid #eeeeee |
### 备注卡片
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|------|-----------|----------|-----------|
| 卡片圆角 | rounded-2xl | 16px | 16px |
| 卡片 padding | p-4 | 16px | 16px |
| 卡片阴影 | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 0 1px 2px rgba(0,0,0,0.05) |
| 卡片间距 | space-y-3 | 12px | 12px |
| 背景色 | bg-white | #ffffff | #ffffff |
### 文本样式
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|------|-----------|----------|-----------|
| 备注内容字号 | text-sm | 14px | 14px |
| 备注内容行高 | leading-relaxed | 1.625 (26px) | 20px |
| 备注内容颜色 | text-gray-13 | #242424 | #242424 |
| 备注内容下边距 | mb-3 | 12px | 12px |
| 标签字号 | text-xs | 12px | 12px |
| 标签 padding | px-2.5 py-1 | 10px 4px | 4px 10px |
| 标签圆角 | rounded-lg | 8px | 8px |
| 时间字号 | text-xs | 12px | 12px |
| 时间颜色 | text-gray-6 | #a6a6a6 | #a6a6a6 |
### 颜色
| 用途 | H5 Tailwind | 色值 | 小程序 WXSS |
|------|-----------|------|-----------|
| 页面背景 | bg-gray-1 | #f3f3f3 | #f3f3f3 |
| 卡片背景 | bg-white | #ffffff | #ffffff |
| 正文 | text-gray-13 | #242424 | #242424 |
| 次级文字 | text-gray-6 | #a6a6a6 | #a6a6a6 |
| 分割线 | border-gray-2 | #eeeeee | #eeeeee |
| 客户标签文字 | text-primary | #0052d9 | #0052d9 |
| 客户标签背景 | gradient | linear-gradient(135deg, #ecf2fe, #e8eeff) | linear-gradient(135deg, #ecf2fe, #e8eeff) |
| 客户标签边框 | — | #c5d4f7 | #c5d4f7 |
| 助教标签文字 | text-success | #00a870 | #00a870 |
| 助教标签背景 | gradient | linear-gradient(135deg, #e8faf0, #e0f7ea) | linear-gradient(135deg, #e8faf0, #e0f7ea) |
| 助教标签边框 | — | #b3e6d0 | #b3e6d0 |
### 列表容器
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|------|-----------|----------|-----------|
| 列表 padding | p-4 | 16px | 16px |
| 列表背景 | bg-gray-1 | #f3f3f3 | #f3f3f3 |
### 底部提示
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|------|-----------|----------|-----------|
| 提示字号 | text-xs | 12px | 12px |
| 提示颜色 | text-gray-5 | #c5c5c5 | #c5c5c5 |
| 提示 padding | py-8 pb-16 | 32px 0 64px | 20px 0 40px |
---
## 关键转换规则
### 1. Tailwind spacing → px
```
p-4 (padding: 1rem) → 16px
p-1 (padding: 0.25rem) → 4px
mb-3 (margin-bottom: 0.75rem) → 12px
space-y-3 (gap: 0.75rem) → 12px
```
### 2. Tailwind 字号 → px
```
text-sm (0.875rem) → 14px
text-xs (0.75rem) → 12px
text-base (1rem) → 16px
```
### 3. Tailwind 圆角 → px
```
rounded-2xl (border-radius: 1rem) → 16px
rounded-lg (border-radius: 0.5rem) → 8px
```
### 4. 行高处理
```
H5: leading-relaxed (1.625)
小程序: 在外层 view 上设置 line-height: 20px
原因: text 组件不支持直接设置 line-height
```
---
## 验证方法
### 在 412px 宽设备上对比
1. 打开H5原型`docs/h5_ui/pages/notes.html`
2. 打开小程序:`pages/notes/notes`
3. 对比以下关键点:
- 导航栏高度是否一致
- 卡片圆角是否一致
- 文字大小是否一致
- 间距是否一致
- 颜色是否一致
### 常见问题排查
| 问题 | 排查项 | 参考文档 |
|------|--------|--------|
| 文字高度不对 | 检查 line-height 是否在外层 view 上设置 | 附录B.2.3 |
| 间距不对 | 检查 padding/margin/gap 数值 | 附录A |
| 颜色不对 | 检查色值是否与附录C一致 | 附录C |
| 圆角不对 | 检查 border-radius 数值 | 附录A.5 |
| 阴影不对 | 检查 box-shadow 参数 | 桥文档 §9.2 |
---
## 文件清单
- `notes.wxml` - 页面结构已更新icon尺寸
- `notes.wxss` - 页面样式已调整为px单位
- `notes.ts` - 页面逻辑(无需修改)
- `notes.json` - 页面配置(无需修改)
- `MIGRATION_NOTES.md` - 迁移说明(本文件)
---
## 参考文档
- 桥文档:`docs/miniprogram-dev/h5-to-mp-bridge-v3.md`
- 附录A`docs/miniprogram-dev/New/appendix_a_spacing_and_sizing_dictionary_v3.md`
- 附录B`docs/miniprogram-dev/New/appendix_b_typography_and_text_dictionary_v3.md`
- 附录C`docs/miniprogram-dev/New/appendix_c_color_dictionary_v3.md`
- H5原型`docs/h5_ui/pages/notes.html`

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "备注",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-empty": "tdesign-miniprogram/empty/empty",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,76 @@
import { mockNotes } from '../../utils/mock-data'
import type { Note } from '../../utils/mock-data'
import { formatRelativeTime } from '../../utils/time'
/** 带展示时间的备注项 */
interface NoteDisplay extends Note {
timeLabel: string
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
notes: [] as NoteDisplay[],
/** 系统状态栏高度px用于自定义导航栏顶部偏移 */
statusBarHeight: 20,
},
onLoad() {
const sysInfo = wx.getWindowInfo()
this.setData({ statusBarHeight: sysInfo.statusBarHeight || 20 })
this.loadData()
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用 GET /api/xcx/notes
try {
const notes: NoteDisplay[] = mockNotes.map((n) => ({
...n,
timeLabel: formatRelativeTime(n.createdAt),
}))
this.setData({
pageState: notes.length > 0 ? 'normal' : 'empty',
notes,
})
} catch {
this.setData({ pageState: 'error' })
}
}, 400)
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
/** 错误态重试 */
onRetry() {
this.loadData()
},
/** 删除备注 */
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
const noteId = e.currentTarget.dataset.id as string
wx.showModal({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
if (res.confirm) {
const notes = this.data.notes.filter((n) => n.id !== noteId)
this.setData({ notes })
wx.showToast({ title: '已删除', icon: 'success' })
}
},
})
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData()
wx.stopPullDownRefresh()
},
})

View File

@@ -0,0 +1,60 @@
<!-- pages/notes/notes.wxml — 备注记录 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-icon">😵</text>
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text class="retry-btn-text">重新加载</text>
</view>
</view>
</view>
<!-- 空数据态 -->
<view class="page-empty-wrap" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty">
<t-empty description="暂无备注记录" />
</view>
</view>
<!-- 正常态 -->
<view class="page-normal" wx:elif="{{pageState === 'normal'}}">
<!-- 备注列表 -->
<view class="note-list">
<view
class="note-card"
wx:for="{{notes}}"
wx:key="id"
>
<text class="note-content">{{item.content}}</text>
<view class="note-bottom">
<text class="note-tag {{item.tagType === 'coach' ? 'tag-coach' : 'tag-customer'}}">{{item.tagLabel}}</text>
<view class="note-bottom-right">
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="16px" color="#a6a6a6" />
</view>
<text class="note-time">{{item.timeLabel}}</text>
</view>
</view>
</view>
</view>
<!-- 底部提示 -->
<view class="list-footer">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button />
</view>
<dev-fab />

View File

@@ -0,0 +1,203 @@
/* pages/notes/notes.wxss — 备注记录页样式 */
/* ========== 全局设置 ========== */
page {
background-color: #f3f3f3;
line-height: 1.5;
}
view {
line-height: inherit;
}
/* ========== 自定义导航栏 ========== */
.safe-area-top {
background-color: #ffffff;
}
.custom-nav {
display: flex;
align-items: center;
height: 44px;
padding: 0 16px;
position: relative;
border-bottom: 1px solid #eeeeee;
}
.nav-back {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.nav-back--hover {
background-color: #eeeeee;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 16px;
font-weight: 500;
color: #242424;
line-height: 24px;
}
/* ========== 页面背景 ========== */
.page-loading,
.page-empty-wrap,
.page-normal,
.page-error {
min-height: 100vh;
background-color: #f3f3f3;
}
/* ========== 加载态 / 空态 ========== */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 16px;
}
/* ========== 错误态 ========== */
.page-error {
display: flex;
flex-direction: column;
height: 100vh;
}
.error-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 40px;
}
.error-icon {
font-size: 54px;
margin-bottom: 16px;
}
.error-text {
font-size: 14px;
color: #777777;
margin-bottom: 20px;
line-height: 20px;
}
.retry-btn {
padding: 10px 30px;
background-color: #0052d9;
border-radius: 8px;
}
.retry-btn--hover {
opacity: 0.8;
}
.retry-btn-text {
font-size: 14px;
color: #ffffff;
line-height: 20px;
}
/* ========== 备注列表 ========== */
.note-list {
padding: 16px;
}
.note-card + .note-card {
margin-top: 12px;
}
/* ========== 备注卡片 ========== */
.note-card {
background-color: #ffffff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.note-content {
display: block;
font-size: 14px;
color: #242424;
line-height: 20px;
margin-bottom: 12px;
}
.note-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.note-bottom-right {
display: flex;
align-items: center;
gap: 8px;
}
/* ========== 删除按钮 ========== */
.note-delete-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
}
.note-delete-btn--hover {
background-color: #f3f3f3;
}
/* ========== 标签样式 ========== */
.note-tag {
padding: 4px 10px;
font-size: 12px;
border-radius: 8px;
border: 1px solid;
line-height: 16px;
}
.tag-customer {
background: linear-gradient(135deg, #ecf2fe, #e8eeff);
color: #0052d9;
border-color: #c5d4f7;
}
.tag-coach {
background: linear-gradient(135deg, #e8faf0, #e0f7ea);
color: #00a870;
border-color: #b3e6d0;
}
.note-time {
font-size: 12px;
color: #a6a6a6;
line-height: 16px;
}
/* ========== 底部提示 ========== */
.list-footer {
text-align: center;
padding: 20px 0 40px;
}
.footer-text {
font-size: 12px;
color: #c5c5c5;
line-height: 16px;
}

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "业绩明细",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,272 @@
import { mockPerformanceRecords } from '../../utils/mock-data'
import type { PerformanceRecord } from '../../utils/mock-data'
import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
/** 按日期分组后的展示结构 */
interface DateGroup {
date: string
totalHours: number
totalIncome: number
totalHoursLabel: string
totalIncomeLabel: string
records: RecordItem[]
}
interface RecordItem {
id: string
customerName: string
avatarChar: string
avatarColor: string
timeRange: string
hours: number // 折算后课时小时number
hoursRaw?: number // 折算前课时小时number可选
courseType: string
courseTypeClass: string
location: string
income: number // 收入(元,整数)
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** Banner */
coachName: '小燕',
coachLevel: '星级',
storeName: '球会名称店',
/** 月份切换 */
currentYear: 2026,
currentMonth: 2,
monthLabel: '2026年2月',
canGoPrev: true,
canGoNext: false,
/** 统计概览 */
totalCount: 0,
totalHours: 0,
totalIncome: 0,
totalCountLabel: '--',
totalHoursLabel: '--',
totalHoursRawLabel: '',
totalIncomeLabel: '--',
/** 按日期分组的记录 */
dateGroups: [] as DateGroup[],
/** 所有记录(用于筛选) */
allRecords: [] as PerformanceRecord[],
/** 分页 */
page: 1,
pageSize: 20,
hasMore: false,
},
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData(() => wx.stopPullDownRefresh())
},
onReachBottom() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1 })
this.loadData()
},
loadData(cb?: () => void) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
// TODO: 替换为真实 API按月份请求
const allRecords = mockPerformanceRecords
const dateGroups: DateGroup[] = [
{
date: '2月7日',
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
totalIncome: 510,
records: [
{ id: 'r1', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
{ id: 'r2', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '16:00-18:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
{ id: 'r3', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
],
},
{
date: '2月6日',
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
totalIncome: 280,
records: [
{ id: 'r4', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
{ id: 'r5', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '15:30-17:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
{
date: '2月5日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 320,
records: [
{ id: 'r6', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
{ id: 'r7', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '14:00-16:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
],
},
{
date: '2月4日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r8', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '19:00-21:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r9', customerName: '吴女士', avatarChar: '吴', avatarColor: nameToAvatarColor('吴'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 160 },
],
},
{
date: '2月3日',
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
totalIncome: 280,
records: [
{ id: 'r10', customerName: '郑先生', avatarChar: '郑', avatarColor: nameToAvatarColor('郑'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
{ id: 'r11', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
{
date: '2月2日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r12', customerName: '林先生', avatarChar: '林', avatarColor: nameToAvatarColor('林'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
{ id: 'r13', customerName: '何女士', avatarChar: '何', avatarColor: nameToAvatarColor('何'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP3号房', income: 190 },
],
},
{
date: '2月1日',
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
totalIncome: 510,
records: [
{ id: 'r14', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:30-22:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
{ id: 'r15', customerName: '马先生', avatarChar: '马', avatarColor: nameToAvatarColor('马'), timeRange: '16:00-18:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '8号台', income: 160 },
{ id: 'r16', customerName: '罗女士', avatarChar: '罗', avatarColor: nameToAvatarColor('罗'), timeRange: '12:30-14:30', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
{ id: 'r17', customerName: '梁先生', avatarChar: '梁', avatarColor: nameToAvatarColor('梁'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
{ id: 'r18', customerName: '宋女士', avatarChar: '宋', avatarColor: nameToAvatarColor('宋'), timeRange: '8:30-10:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
{ id: 'r19', customerName: '谢先生', avatarChar: '谢', avatarColor: nameToAvatarColor('谢'), timeRange: '7:00-8:00', hours: 1.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 80 },
],
},
{
date: '1月31日',
totalHours: 5.5, totalHoursLabel: formatHours(5.5), totalIncome: 470, totalIncomeLabel: formatMoney(470),
records: [
{ id: 'r20', customerName: '韩女士', avatarChar: '韩', avatarColor: nameToAvatarColor('韩'), timeRange: '21:00-23:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
{ id: 'r21', customerName: '唐先生', avatarChar: '唐', avatarColor: nameToAvatarColor('唐'), timeRange: '18:30-20:30', hours: 2.0, hoursRaw: 2.5, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r22', customerName: '冯女士', avatarChar: '冯', avatarColor: nameToAvatarColor('冯'), timeRange: '14:00-16:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
],
},
{
date: '1月30日',
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
totalIncome: 280,
records: [
{ id: 'r23', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:30-21:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
{ id: 'r24', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
{
date: '1月29日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 320,
records: [
{ id: 'r25', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
{ id: 'r26', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
],
},
{
date: '1月28日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r27', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '19:00-21:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
{ id: 'r28', customerName: '董先生', avatarChar: '董', avatarColor: nameToAvatarColor('董'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
],
},
{
date: '1月27日',
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
totalIncome: 350,
records: [
{ id: 'r29', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '20:00-22:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
{ id: 'r30', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:30', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
],
},
]
this.setData({
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
allRecords,
dateGroups,
totalCount: 32,
totalHours: 59.0,
totalIncome: 4720,
totalCountLabel: formatCount(32, '笔'),
totalHoursLabel: formatHours(59.0),
totalHoursRawLabel: formatHours(63.5),
totalIncomeLabel: formatMoney(4720),
hasMore: false,
})
} catch (_err) {
this.setData({ pageState: 'error' })
}
cb?.()
}, 500)
},
/** 重试加载 */
onRetry() {
this.loadData()
},
/** 返回上一页 */
onNavBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/task-list/task-list' }) })
},
/** 切换月份 */
switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
if (direction === 'prev') {
currentMonth--
if (currentMonth < 1) {
currentMonth = 12
currentYear--
}
} else {
currentMonth++
if (currentMonth > 12) {
currentMonth = 1
currentYear++
}
}
// 不能超过当前月
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
this.setData({
currentYear,
currentMonth,
monthLabel: `${currentYear}${currentMonth}`,
canGoNext,
canGoPrev: true,
})
this.loadData()
},
})

View File

@@ -0,0 +1,130 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- toast 加载浮层fixed不销毁内容不白屏 -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 错误态(全屏,仅在 error 时展示) -->
<view class="page-error" wx:if="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text>重试</text>
</view>
</view>
<!-- 主体内容(始终挂载,不随 loading 销毁) -->
<block wx:if="{{pageState !== 'error'}}">
<!-- Banner 区域(复用助教详情样式) -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
<view class="banner-overlay">
<view class="coach-header">
<view class="avatar-box">
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
</view>
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{coachName}}</text>
<coach-level-tag level="{{coachLevel}}" />
</view>
<view class="skill-row">
<text class="store-name-text">{{storeName}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="prev" bindtap="switchMonth">
<t-icon name="chevron-left" size="32rpx" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="next" bindtap="switchMonth">
<t-icon name="chevron-right" size="32rpx" />
</view>
</view>
<!-- 统计概览 -->
<view class="stats-overview">
<view class="stat-item">
<text class="stat-label">总记录</text>
<text class="stat-value">{{totalCountLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">总业绩时长</text>
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
<text class="stat-hint">预估</text>
<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">收入</text>
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
<text class="stat-hint">预估</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无数据</text>
</view>
<!-- 记录列表 -->
<view class="records-container" wx:elif="{{pageState === 'normal' || pageState === 'loading'}}">
<view class="records-card">
<block wx:for="{{dateGroups}}" wx:key="date">
<!-- 日期分隔线 -->
<view class="date-divider">
<text decode class="dd-date">{{item.date}}&nbsp;&nbsp;—</text>
<text decode class="dd-stats" wx:if="{{item.totalHoursLabel}}">{{item.totalHoursLabel}}&nbsp;&nbsp;·&nbsp;&nbsp;预估 {{item.totalIncomeLabel}}&nbsp;&nbsp;&nbsp;&nbsp;</text>
<view class="dd-line"></view>
</view>
<!-- 该日期下的记录 -->
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="id"
hover-class="record-item--hover">
<view class="record-avatar avatar-{{rec.avatarColor}}">
<text>{{rec.avatarChar}}</text>
</view>
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name">{{rec.customerName}}</text>
<text class="record-time">{{rec.timeRange}}</text>
</view>
<view class="record-hours-wrap">
<text class="record-hours">{{fmt.hours(rec.hours)}}</text>
<text class="record-hours-deduct" wx:if="{{rec.hoursRaw}}">(折后 {{fmt.hours(rec.hoursRaw)}}</text>
</view>
</view>
<view class="record-bottom">
<view class="record-tags">
<text class="course-tag {{rec.courseTypeClass}}">{{rec.courseType}}</text>
<text class="record-location">{{rec.location}}</text>
</view>
<text class="record-income">我的预估收入 <text class="record-income-val">{{fmt.money(rec.income)}}</text></text>
</view>
</view>
</view>
</block>
<!-- 列表底部提示 -->
<view class="list-end-hint">
<text>— 已加载全部记录 —</text>
</view>
</view>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<dev-fab />

View File

@@ -0,0 +1,492 @@
/* pages/performance-records/performance-records.wxss */
page {
background-color: #f3f3f3;
line-height: 1.5;
}
view {
line-height: inherit;
}
/* ============================================
* 加载态 / 空态 / 错误态
* ============================================ */
/* toast 风格加载(浮在页面中央,不全屏变白) */
.toast-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
pointer-events: none;
}
.toast-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
background: rgba(36, 36, 36, 0.75);
border-radius: 24rpx;
padding: 36rpx 48rpx;
pointer-events: auto;
}
.toast-loading-text {
font-size: 24rpx;
color: #ffffff;
line-height: 32rpx;
}
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-text {
font-size: 26rpx;
color: #a6a6a6;
}
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 24rpx;
}
.error-text {
font-size: 28rpx;
color: #a6a6a6;
}
.retry-btn {
padding: 16rpx 48rpx;
background: #0052d9;
color: #ffffff;
border-radius: 16rpx;
font-size: 28rpx;
}
.retry-btn--hover {
opacity: 0.7;
}
/* ============================================
* Banner复用助教详情
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
height: 100%;
}
.banner-bg-img {
position: absolute;
top: -50rpx;
left: 0;
width: 100%;
height: auto;
z-index: 0;
}
.banner-overlay {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 36rpx 40rpx;
}
.coach-header {
display: flex;
align-items: center;
gap: 32rpx;
}
.avatar-box {
width: 98rpx;
height: 98rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-img {
width: 100%;
height: 100%;
}
.info-middle {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 8rpx;
}
.coach-name {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.skill-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.skill-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
}
/* 助教等级 tag — 遵循 VI 规范§5 助教等级配色) */
.coach-level-tag {
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
line-height: 29rpx;
}
.coach-level-star { color: #fbbf24; background: #fffef0; }
.coach-level-senior { color: #e91e63; background: #ffe6e8; }
.coach-level-middle { color: #ed7b2f; background: #fff3e6; }
.coach-level-junior { color: #0052d9; background: #ecf2fe; }
/* 球会名称 — 纯文字,无背景 */
.store-name-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 29rpx;
}
/* ============================================
* 月份切换
* ============================================ */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn-disabled {
opacity: 0.3;
pointer-events: none;
}
.month-btn--hover {
opacity: 0.6;
}
.month-label {
font-size: 28rpx;
font-weight: 600;
color: #242424;
}
/* ============================================
* 统计概览
* ============================================ */
.stats-overview {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.stat-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 20rpx;
color: #a6a6a6;
margin-bottom: 4rpx;
}
.stat-value {
font-size: 36rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
}
.stat-primary {
color: #0052d9;
}
.stat-success {
color: #00a870;
}
.stat-sub-hint {
font-size: 20rpx;
color: #c5c5c5;
margin-top: 2rpx;
line-height: 26rpx;
}
.stat-hours-raw {
font-size: 20rpx;
color: #a6a6a6;
margin-top: 2rpx;
line-height: 26rpx;
}
.stat-hint {
font-size: 20rpx;
color: #ed7b2f;
margin-top: 2rpx;
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: #eeeeee;
margin-top: 4rpx;
}
/* ============================================
* 记录列表
* ============================================ */
.records-container {
padding: 24rpx;
padding-bottom: 40rpx;
}
.records-card {
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.date-divider {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 32rpx 8rpx;
}
.dd-date {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 500;
white-space: nowrap;
line-height: 29rpx;
}
.dd-line {
flex: 1;
height: 2rpx;
background: #dcdcdc;
}
.dd-stats {
font-size: 22rpx;
color: #a6a6a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 29rpx;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 32rpx;
}
.record-item--hover {
background: #f7f7f7;
}
/* 头像 */
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
flex-shrink: 0;
}
.avatar-from-blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar-from-pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar-from-teal { background: linear-gradient(135deg, #2dd4bf, #10b981); }
.avatar-from-green { background: linear-gradient(135deg, #4ade80, #14b8a6); }
.avatar-from-orange { background: linear-gradient(135deg, #fb923c, #f59e0b); }
.avatar-from-purple { background: linear-gradient(135deg, #c084fc, #8b5cf6); }
.avatar-from-violet { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
.avatar-from-amber { background: linear-gradient(135deg, #fbbf24, #eab308); }
.avatar-from-sky { background: linear-gradient(135deg, #38bdf8, #0ea5e9); }
.avatar-from-lime { background: linear-gradient(135deg, #a3e635, #65a30d); }
.avatar-from-rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
.avatar-from-fuchsia{ background: linear-gradient(135deg, #e879f9, #a21caf); }
.avatar-from-slate { background: linear-gradient(135deg, #94a3b8, #475569); }
.avatar-from-indigo { background: linear-gradient(135deg, #818cf8, #4338ca); }
.avatar-from-cyan { background: linear-gradient(135deg, #22d3ee, #0891b2); }
.avatar-from-yellow { background: linear-gradient(135deg, #facc15, #ca8a04); }
/* 内容区 */
.record-content {
flex: 1;
min-width: 0;
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.record-name-time {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.record-name {
font-size: 26rpx;
font-weight: 500;
color: #242424;
flex-shrink: 0;
line-height: 36rpx;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-hours-wrap {
display: flex;
align-items: baseline;
gap: 6rpx;
flex-shrink: 0;
}
.record-hours {
font-size: 26rpx;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
line-height: 36rpx;
}
.record-hours-deduct {
font-size: 20rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.record-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.course-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
line-height: 29rpx;
}
.tag-basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
background: #fffbeb;
color: #a16207;
}
.record-location {
font-size: 22rpx;
color: #8b8b8b;
line-height: 29rpx;
}
.record-income {
font-size: 22rpx;
color: #c5c5c5;
flex-shrink: 0;
line-height: 29rpx;
}
.record-income-val {
font-weight: 500;
color: #5e5e5e;
}
/* 列表底部提示 */
.list-end-hint {
text-align: center;
padding: 24rpx 0 28rpx;
font-size: 22rpx;
color: #c5c5c5;
}

View File

@@ -0,0 +1,297 @@
# Performance 页面迁移说明
## 迁移版本
- **H5 原型基准**`docs/h5_ui/pages/performance.html`
- **桥文档版本**v3.0
- **迁移日期**2026-03-15
- **更新日期**2026-03-15Banner背景改用灵活的SVG实现
---
## 样式调整清单
### 1. 单位转换规则
遵循桥文档 §2.3 的混合单位策略:
- **主要使用 `rpx`**:页面宽度、横向布局、卡片尺寸、间距
- **使用 `px`**导航栏高度44px、图标点击区域
- **换算基准**H5 CSS px → 小程序 rpx750宽基准1px = 1.8204rpx
### 2. 自定义导航栏(.custom-nav
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| height | 44px | 44px | 标准导航栏高度使用px |
| font-size | text-base (16px) | 32rpx | 标题字号 |
| icon size | w-5 h-5 (20px) | 24px | 返回按钮图标 |
### 3. Banner 区域(.banner-section
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| 背景方案 | CSS 渐变 | SVG 图片 | 使用 `/assets/images/banner-bg-combined.svg` |
| height | - | 480rpx | Banner 高度(可通过 WXSS 调整) |
| padding | px-5 pt-2 pb-2 (20px 8px) | 24rpx 40rpx 32rpx | 内容区内边距 |
#### Banner SVG 背景实现(灵活可调)
- **方案**SVG 做渐变底图(与 task-detail/task-list 保持一致)
- **原因**SVG pattern 在小程序 image 组件中不渲染
- **WXML 实现**
```wxml
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-combined.svg" mode="aspectFill" />
<view class="banner-content">
<!-- 内容 -->
</view>
</view>
```
- **WXSS 实现**(可灵活调整):
```wxss
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
height: 480rpx; /* 调整此值改变 Banner 高度 */
}
.banner-bg-img {
position: absolute;
top: 0; /* 调整此值改变背景垂直位置 */
left: 0; /* 调整此值改变背景水平位置 */
width: 100%;
height: 100%;
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 24rpx 40rpx 32rpx; /* 调整此值改变内容区内边距 */
height: 100%;
display: flex;
flex-direction: column;
}
```
- **调整方式**
- 修改 `.banner-section` 的 `height` 调整 Banner 高度
- 修改 `.banner-bg-img` 的 `top/left` 调整背景位置
- 修改 `.banner-bg-img` 的 `width/height` 调整背景缩放
- 修改 `.banner-content` 的 `padding` 调整内容区域
#### 个人信息卡片
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| avatar size | w-14 h-14 (56px) | 112rpx | 头像尺寸 |
| avatar radius | rounded-2xl (16px) | 24rpx | 头像圆角 |
| name font-size | text-xl (20px) | 40rpx | 姓名字号 |
| role tag padding | px-2 py-0.5 (8px 2px) | 4rpx 16rpx | 角色标签内边距 |
#### 收入概览卡片
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| grid gap | gap-3 (12px) | 24rpx | 卡片间距 |
| card padding | p-4 (16px) | 24rpx | 卡片内边距 |
| card radius | rounded-xl (12px) | 24rpx | 卡片圆角 |
| label font-size | text-xs (12px) | 22rpx | 标签字号 |
| value font-size | text-2xl (24px) | 44rpx | 数值字号 |
### 4. Section 卡片(.section-card
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| border-radius | rounded-2xl (16px) | 32rpx | 卡片圆角 |
| padding | p-4 (16px) | 32rpx | 卡片内边距 |
| margin | m-4 (16px) | 24rpx 24rpx 0 | 卡片外边距 |
| box-shadow | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 阴影 |
### 5. 收入档位卡片(.tier-card
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| padding | p-3 (12px) | 24rpx | 内边距 |
| border-radius | rounded-xl (12px) | 24rpx | 圆角 |
| border | 1px solid | 2rpx solid | 边框 |
| emoji size | text-2xl (24px) | 48rpx | 表情图标 |
| rate value | text-lg (18px) | 36rpx | 费率数值 |
| rate unit | text-xs (12px) | 22rpx | 单位字号 |
| rate desc | text-[10px] | 20rpx | 描述字号 |
#### 档位徽章
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| top | -8px | -16rpx | 顶部偏移 |
| padding | 2px 10px | 4rpx 20rpx | 内边距 |
| font-size | 10px | 20rpx | 字号 |
### 6. 升级提示(.upgrade-hint
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| padding | p-3 (12px) | 24rpx | 内边距 |
| border-radius | rounded-xl (12px) | 24rpx | 圆角 |
| emoji size | text-lg (18px) | 36rpx | 表情大小 |
| label font-size | text-xs (12px) | 22rpx | 标签字号 |
| hours font-size | text-sm (14px) | 26rpx | 小时数字号 |
| bonus value | text-lg (18px) | 36rpx | 奖金数值 |
### 7. 业绩明细列表(.income-list
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| icon box size | w-8 h-8 (32px) | 64rpx | 图标容器 |
| icon box radius | rounded-lg (8px) | 16rpx | 图标圆角 |
| label font-size | text-sm (14px) | 26rpx | 标签字号 |
| desc font-size | text-[10px] | 20rpx | 描述字号 |
| value font-size | text-base (16px) | 30rpx | 数值字号 |
| total value | text-xl (20px) | 40rpx | 合计数值 |
### 8. 服务记录(.service-records-section
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| avatar size | w-[38px] h-[38px] | 76rpx | 头像尺寸 |
| avatar radius | rounded-lg (8px) | 16rpx | 头像圆角 |
| name font-size | text-sm (14px) | 26rpx | 姓名字号 |
| time font-size | text-xs (12px) | 22rpx | 时间字号 |
| hours font-size | text-sm (14px) | 26rpx | 小时数字号 |
| tag padding | px-1.5 py-px (6px 1px) | 2rpx 12rpx | 标签内边距 |
| tag font-size | text-[11px] | 22rpx | 标签字号 |
#### 日期分隔线
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| date font-size | 11px | 22rpx | 日期字号 |
| line height | 1px | 2rpx | 分隔线高度 |
| padding | 14px 16px 4px | 20rpx 0 8rpx | 内边距 |
### 9. 客户列表(.customer-list
| 属性 | H5 值 | 小程序值 | 说明 |
|------|------|--------|------|
| avatar size | w-[38px] h-[38px] | 76rpx | 头像尺寸 |
| avatar radius | rounded-lg (8px) | 16rpx | 头像圆角 |
| name font-size | text-sm (14px) | 26rpx | 姓名字号 |
| detail font-size | text-xs (12px) | 22rpx | 详情字号 |
| icon size | - | 24px | 右侧箭头图标 |
### 10. 颜色映射附录C
| 用途 | 色值 | 说明 |
|------|------|------|
| 主品牌色 | #0052d9 | primary |
| 成功色 | #00a870 | success |
| 背景色 | #f3f3f3 | gray-1 |
| 正文色 | #242424 | gray-13 |
| 次级文字 | #a6a6a6 | gray-6 |
| 三级文字 | #8b8b8b | gray-7 |
| 四级文字 | #c5c5c5 | gray-5 |
| 分割线 | #f3f3f3 | gray-1 |
### 11. 渐变色系统
#### 头像渐变8种
- `from-blue`: #60a5fa → #6366f1
- `from-pink`: #f472b6 → #f43f5e
- `from-teal`: #2dd4bf → #10b981
- `from-green`: #4ade80 → #14b8a6
- `from-orange`: #fb923c → #f59e0b
- `from-purple`: #c084fc → #8b5cf6
- `from-violet`: #a78bfa → #7c3aed
- `from-amber`: #fbbf24 → #eab308
#### 档位卡片渐变
- 当前档位:#f0fdf4 → #dcfce7绿色系
- 下一阶段:#fefce8 → #fef9c3黄色系
- 升级提示:#eff6ff → #eef2ff蓝色系
- 奖金按钮:#fbbf24 → #f97316橙色系
---
## 关键调整点
### 1. 全局 line-height 设置
```wxss
page {
line-height: 1.5;
}
view {
line-height: inherit;
}
```
**原因**:微信小程序 `<text>` 组件不能直接设置 `line-height`,必须在外层 `<view>` 设置附录B.2.3
### 2. 单位统一为 rpx除特殊情况
- 主要尺寸使用 `rpx`,确保不同设备自适应
- 导航栏高度、图标点击区域使用 `px`
- 换算公式1px = 1.8204rpx750宽基准
### 3. 移除 CSS 变量依赖
- 原:`var(--color-gray-2, #eeeeee)`
- 新:直接使用 `#eeeeee`
- 原因:确保样式独立,不依赖全局变量
### 4. Icon 尺寸调整
- 导航栏返回按钮:`size="24px"`(标准点击区域)
- 列表箭头图标:`size="24px"`
- 加载/错误图标:`size="40px"`
### 5. 复杂背景还原
- **Banner 背景**:使用 SVG 图片 `/assets/images/banner-bg-combined.svg`(与 task-detail/task-list 保持一致)
- 原因SVG pattern 在小程序 image 组件中不渲染
- **灵活可调**:通过 WXSS 可调整 Banner 高度、背景位置、内容区内边距
- 档位卡片使用双色渐变背景
- 头像使用 8 种预设渐变色
- 升级提示使用渐变背景 + 渐变按钮
### 6. 数值字体特性
```wxss
.perf-value {
font-variant-numeric: tabular-nums;
}
```
确保数字等宽对齐
---
## 验收清单
- [x] 导航栏高度与H5对齐44px
- [x] Banner SVG背景与H5对齐
- [x] Banner 高度可通过 WXSS 调整480rpx
- [x] Banner 背景位置可通过 WXSS 调整
- [x] 个人信息卡片尺寸与H5对齐
- [x] 收入概览卡片样式与H5对齐
- [x] 档位卡片渐变背景与H5对齐
- [x] 档位徽章位置与H5对齐
- [x] 升级提示样式与H5对齐
- [x] 业绩明细列表与H5对齐
- [x] 服务记录样式与H5对齐
- [x] 日期分隔线与H5对齐
- [x] 客户列表样式与H5对齐
- [x] 头像渐变色与H5对齐
- [x] 所有字号与H5对齐
- [x] 所有间距与H5对齐
- [x] 所有圆角与H5对齐
- [x] 所有颜色与H5对齐
- [x] 行高设置正确在view上设置
- [x] Banner背景使用灵活的SVG实现与task-detail保持一致
---
## 已知限制
1. **动画效果**H5 中的 `bonus-highlight` 闪烁动画未实现(需要 CSS animation
2. **backdrop-filter**:收入卡片的 `backdrop-blur` 效果在部分安卓设备可能不支持
3. **SVG兼容性**banner-bg-combined.svg 需要确保存在于 `/assets/images/` 目录
---
## 后续维护
- 若真机测试发现尺寸偏差,优先检查 line-height 设置
- 若需要调整 Banner 高度,修改 `.banner-section` 的 `height` 属性
- 若需要调整 Banner 背景位置,修改 `.banner-bg-img` 的 `top/left` 属性
- 若需要调整间距参考附录A的spacing表
- 若需要调整字号参考附录B的字体表
- 颜色调整参考附录C的颜色字典
- Banner背景问题参考task-detail页面的实现方式

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "业绩详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"metric-card": "/components/metric-card/metric-card",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,226 @@
// TODO: 联调时替换为真实 API 调用
import { initPageAiColor } from '../../utils/ai-color-manager'
/** 业绩明细项(本月/上月) */
interface IncomeItem {
icon: string
label: string
desc: string
value: string
}
/** 服务记录(按日期分组后的展示结构) */
interface ServiceRecord {
customerName: string
avatarChar: string
avatarColor: string
timeRange: string
hours: string
courseType: string
courseTypeClass: string
location: string
income: string
}
interface DateGroup {
date: string
totalHours?: string
totalIncome?: string
records: ServiceRecord[]
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** Banner 数据 */
coachName: '小燕',
coachRole: '助教',
storeName: '广州朗朗桌球',
monthlyIncome: '¥6,206',
lastMonthIncome: '¥16,880',
/** 收入档位 */
currentTier: {
basicRate: 80,
incentiveRate: 95,
},
nextTier: {
basicRate: 90,
incentiveRate: 114,
},
upgradeHoursNeeded: 15,
upgradeBonus: 800,
/** 本月业绩明细 */
incomeItems: [] as IncomeItem[],
monthlyTotal: '¥6,950.5',
/** 服务记录 */
thisMonthRecords: [] as DateGroup[],
thisMonthRecordsExpanded: false,
/** 默认显示前 N 条日期组 */
visibleRecordGroups: 2,
/** 新客列表 */
newCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; lastService: string; count: number }>,
newCustomerExpanded: false,
/** 常客列表 */
regularCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; hours: number; income: string; count: number }>,
regularCustomerExpanded: false,
},
onLoad() {
// 初始化 AI 图标配色(蓝色 - 数据分析感)
const { aiColor } = initPageAiColor('performance')
this.setData({ aiColor })
this.loadData()
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
// TODO: 替换为真实 API
const incomeItems: IncomeItem[] = [
{ icon: '🎱', label: '基础课', desc: '80元/h × 75h', value: '¥6,000' },
{ icon: '⭐', label: '激励课', desc: '95.05元/h × 10h', value: '¥950.5' },
{ icon: '💰', label: '充值激励', desc: '客户充值返佣', value: '¥500' },
{ icon: '🏆', label: 'TOP3 销冠奖', desc: '全店业绩前三名奖励', value: '继续努力' },
]
const gradients = [
'blue', 'pink', 'teal', 'green',
'orange', 'purple', 'violet', 'amber',
]
// 模拟服务记录按日期分组
const thisMonthRecords: DateGroup[] = [
{
date: '2月7日',
totalHours: '4.0h',
totalIncome: '¥350',
records: [
{ customerName: '王先生', avatarChar: '王', avatarColor: gradients[0], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: '¥160' },
{ customerName: '李女士', avatarChar: '李', avatarColor: gradients[1], timeRange: '16:00-18:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: '¥190' },
],
},
{
date: '2月6日',
totalHours: '2.0h',
totalIncome: '¥160',
records: [
{ customerName: '张先生', avatarChar: '张', avatarColor: gradients[2], timeRange: '19:00-21:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: '¥160' },
],
},
{
date: '2月5日',
totalHours: '4.0h',
totalIncome: '¥320',
records: [
{ customerName: '陈女士', avatarChar: '陈', avatarColor: gradients[2], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
{ customerName: '赵先生', avatarChar: '赵', avatarColor: gradients[5], timeRange: '14:00-16:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: '¥160' },
],
},
{
date: '2月4日',
totalHours: '4.0h',
totalIncome: '¥350',
records: [
{ customerName: '孙先生', avatarChar: '孙', avatarColor: gradients[6], timeRange: '19:00-21:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: '¥190' },
{ customerName: '吴女士', avatarChar: '吴', avatarColor: gradients[2], timeRange: '15:00-17:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: '¥160' },
],
},
]
const newCustomers = [
{ name: '王先生', avatarChar: '王', avatarColor: gradients[0], lastService: '2月7日', count: 2 },
{ name: '李女士', avatarChar: '李', avatarColor: gradients[1], lastService: '2月7日', count: 1 },
{ name: '刘先生', avatarChar: '刘', avatarColor: gradients[4], lastService: '2月6日', count: 1 },
{ name: '周女士', avatarChar: '周', avatarColor: gradients[3], lastService: '2月5日', count: 1 },
{ name: '吴先生', avatarChar: '吴', avatarColor: gradients[7], lastService: '2月4日', count: 1 },
{ name: '郑女士', avatarChar: '郑', avatarColor: gradients[5], lastService: '2月3日', count: 1 },
{ name: '钱先生', avatarChar: '钱', avatarColor: gradients[6], lastService: '2月2日', count: 1 },
{ name: '冯女士', avatarChar: '冯', avatarColor: gradients[2], lastService: '2月1日', count: 1 },
]
const regularCustomers = [
{ name: '张先生', avatarChar: '张', avatarColor: gradients[2], hours: 12, income: '¥960', count: 6 },
{ name: '陈女士', avatarChar: '陈', avatarColor: gradients[2], hours: 10, income: '¥800', count: 5 },
{ name: '赵先生', avatarChar: '赵', avatarColor: gradients[5], hours: 8, income: '¥640', count: 4 },
{ name: '孙先生', avatarChar: '孙', avatarColor: gradients[6], hours: 6, income: '¥570', count: 3 },
{ name: '杨女士', avatarChar: '杨', avatarColor: 'pink', hours: 6, income: '¥480', count: 3 },
{ name: '黄先生', avatarChar: '黄', avatarColor: 'amber', hours: 4, income: '¥380', count: 2 },
{ name: '林女士', avatarChar: '林', avatarColor: 'green', hours: 4, income: '¥320', count: 2 },
{ name: '徐先生', avatarChar: '徐', avatarColor: 'orange', hours: 2, income: '¥190', count: 1 },
]
this.setData({
pageState: 'normal',
incomeItems,
thisMonthRecords,
newCustomers,
regularCustomers,
})
} catch (_e) {
this.setData({ pageState: 'error' })
}
}, 500)
},
/** 加载失败重试 */
onRetry() {
this.loadData()
},
/** 自定义导航栏返回 */
onNavBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/task-list/task-list' }) })
},
/** 展开/收起本月服务记录 */
toggleThisMonthRecords() {
this.setData({ thisMonthRecordsExpanded: !this.data.thisMonthRecordsExpanded })
},
/** 查看全部 → 跳转业绩明细 */
goToRecords() {
wx.navigateTo({ url: '/pages/performance-records/performance-records' })
},
/** 展开/收起新客列表 */
toggleNewCustomer() {
this.setData({ newCustomerExpanded: !this.data.newCustomerExpanded })
},
/** 展开/收起常客列表 */
toggleRegularCustomer() {
this.setData({ regularCustomerExpanded: !this.data.regularCustomerExpanded })
},
/** 点击客户卡片 → 跳转任务详情 */
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
const { name } = e.currentTarget.dataset
wx.navigateTo({
url: `/pages/task-detail/task-detail?customerName=${name}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 点击服务记录 → 跳转任务详情 */
onRecordTap(e: WechatMiniprogram.TouchEvent) {
const { customerName, taskId } = e.currentTarget.dataset
wx.navigateTo({
url: `/pages/task-detail/task-detail?customerName=${customerName}${taskId ? `&taskId=${taskId}` : ''}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 点击收入概览卡片 → 跳转业绩记录 */
onIncomeCardTap() {
wx.navigateTo({ url: '/pages/performance-records/performance-records' })
},
})

View File

@@ -0,0 +1,294 @@
<!-- pages/performance/performance.wxml — 业绩总览 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空数据态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="40px" color="#dcdcdc" />
<text class="empty-text">暂无业绩数据</text>
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-icon name="close-circle" size="40px" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text>重试</text>
</view>
</view>
<!-- 正常态 -->
<block wx:else>
<!-- Banner 区域 — SVG 做渐变底图 -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-blue-light-aurora.svg" mode="widthFix" />
<view class="banner-content">
<!-- 个人信息 -->
<view class="coach-info">
<view class="coach-avatar">
<image src="/assets/images/avatar-coach.png" class="avatar-img" mode="aspectFill" />
</view>
<view class="coach-meta">
<view class="coach-name-row">
<text class="coach-name">{{coachName}}</text>
<text class="coach-role-tag">{{coachRole}}</text>
</view>
<text class="coach-store">{{storeName}}</text>
</view>
</view>
<!-- 核心收入数据 -->
<view class="income-overview">
<view class="income-card">
<text class="income-label">本月预计收入</text>
<text class="income-value">{{monthlyIncome}}</text>
</view>
<view class="income-card">
<text class="income-label">上月收入</text>
<text class="income-value income-highlight">{{lastMonthIncome}}</text>
</view>
</view>
</view>
</view>
<!-- 收入情况 -->
<view class="section-card">
<view class="section-title">
<view class="title-dot dot-primary"></view>
<text>收入情况</text>
</view>
<!-- 当前档位 -->
<view class="tier-card tier-current">
<view class="tier-badge badge-current">当前档位</view>
<view class="tier-row">
<view class="tier-icon-label">
<text class="tier-emoji">📊</text>
<text class="tier-label tier-label-green">当前档位</text>
</view>
<view class="tier-rates">
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-green">{{currentTier.basicRate}}</text>
<text class="rate-unit rate-green-light">元/h</text>
</view>
<text class="rate-desc rate-green-light">基础课到手</text>
</view>
<view class="rate-divider"></view>
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-green">{{currentTier.incentiveRate}}</text>
<text class="rate-unit rate-green-light">元/h</text>
</view>
<text class="rate-desc rate-green-light">激励课到手</text>
</view>
</view>
</view>
</view>
<!-- 下一阶段 -->
<view class="tier-card tier-next">
<view class="tier-badge badge-next">下一阶段</view>
<view class="tier-row">
<view class="tier-icon-label">
<text class="tier-emoji">🎯</text>
<text class="tier-label tier-label-yellow">下一阶段</text>
</view>
<view class="tier-rates">
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-yellow">{{nextTier.basicRate}}</text>
<text class="rate-unit rate-yellow-light">元/h</text>
</view>
<text class="rate-desc rate-yellow-light">基础课到手</text>
</view>
<view class="rate-divider rate-divider-yellow"></view>
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-yellow">{{nextTier.incentiveRate}}</text>
<text class="rate-unit rate-yellow-light">元/h</text>
</view>
<text class="rate-desc rate-yellow-light">激励课到手</text>
</view>
</view>
</view>
</view>
<!-- 升级提示 -->
<view class="upgrade-hint">
<view class="upgrade-left">
<text class="upgrade-emoji">⏱️</text>
<view class="upgrade-text">
<text class="upgrade-label">距离下一阶段</text>
<view class="upgrade-hours">
<text>需完成 </text>
<text class="upgrade-hours-num">{{upgradeHoursNeeded}}</text>
<text> 小时</text>
</view>
</view>
</view>
<view class="upgrade-bonus">
<text class="bonus-label">到达即得</text>
<text class="bonus-value">{{upgradeBonus}}元</text>
</view>
</view>
</view>
<!-- 本月业绩 -->
<view class="section-card">
<view class="section-title">
<view class="title-dot dot-success"></view>
<text>本月业绩 预估</text>
</view>
<view class="income-list">
<view class="income-row" wx:for="{{incomeItems}}" wx:key="label">
<view class="income-row-left">
<view class="income-icon-box">
<text>{{item.icon}}</text>
</view>
<view class="income-info">
<text class="income-item-label">{{item.label}}</text>
<text class="income-item-desc">{{item.desc}}</text>
</view>
</view>
<text class="income-item-value">{{item.value}}</text>
</view>
</view>
<!-- 合计 -->
<view class="income-total">
<text class="total-label">本月合计 预估</text>
<text class="total-value">{{monthlyTotal}}</text>
</view>
<!-- 服务记录明细 -->
<view class="service-records-section">
<view class="service-records-header">
<text class="service-records-emoji">📋</text>
<text class="service-records-title">我的服务记录明细</text>
</view>
<block wx:for="{{thisMonthRecords}}" wx:key="date" wx:if="{{thisMonthRecordsExpanded || index < visibleRecordGroups}}">
<view class="date-divider">
<text decode class="dd-date">{{item.date}}&nbsp;—</text>
<text decode class="dd-stats" wx:if="&nbsp;{{item.totalHours}}">{{item.totalHours}}&nbsp;·&nbsp;{{item.totalIncome}}&nbsp;&nbsp;</text>
<view class="dd-line"></view>
</view>
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName" bindtap="onRecordTap" data-customer-name="{{rec.customerName}}" data-task-id="{{rec.taskId}}">
<view class="record-avatar avatar-{{rec.avatarColor}}">
<text>{{rec.avatarChar}}</text>
</view>
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name">{{rec.customerName}}</text>
<text class="record-time">{{rec.timeRange}}</text>
</view>
<text class="record-hours">{{rec.hours}}</text>
</view>
<view class="record-bottom">
<view class="record-tags">
<text class="course-tag {{rec.courseTypeClass}}">{{rec.courseType}}</text>
<text class="record-location">{{rec.location}}</text>
</view>
<text class="record-income">我的预估收入 <text class="record-income-val">{{rec.income}}</text></text>
</view>
</view>
</view>
</block>
<view class="records-actions">
<view class="records-toggle" hover-class="toggle-btn--hover" bindtap="toggleThisMonthRecords" wx:if="{{thisMonthRecords.length > visibleRecordGroups}}">
<text>{{thisMonthRecordsExpanded ? '收起' : '展开更多'}}</text>
<t-icon name="{{thisMonthRecordsExpanded ? 'chevron-up' : 'chevron-down'}}" size="24px" />
</view>
<view class="records-view-all" hover-class="view-all--hover" bindtap="goToRecords">
<text>查看全部</text>
<t-icon name="chevron-right" size="24px" color="#0052d9" />
</view>
</view>
</view>
</view>
<!-- 我的新客 -->
<view class="section-card">
<view class="section-title">
<view class="title-dot dot-cyan"></view>
<text>我的新客</text>
</view>
<view class="customer-list">
<view
class="customer-item"
hover-class="customer-item--hover"
wx:for="{{newCustomers}}"
wx:key="name"
wx:if="{{newCustomerExpanded ? index < 20 : index < 5}}"
data-name="{{item.name}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.avatarColor}}">
<text>{{item.avatarChar}}</text>
</view>
<view class="customer-info">
<text class="customer-name">{{item.name}}</text>
<text class="customer-detail">最近服务: {{item.lastService}} · {{item.count}}次</text>
</view>
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
</view>
</view>
<view class="toggle-btn" hover-class="toggle-btn--hover" bindtap="toggleNewCustomer" wx:if="{{newCustomers.length > 5}}">
<text>{{newCustomerExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
</view>
</view>
<!-- 我的常客 -->
<view class="section-card">
<view class="section-title">
<view class="title-dot dot-pink"></view>
<text>我的常客</text>
</view>
<view class="customer-list">
<view
class="customer-item"
hover-class="customer-item--hover"
wx:for="{{regularCustomers}}"
wx:key="name"
wx:if="{{regularCustomerExpanded ? index < 20 : index < 5}}"
data-name="{{item.name}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.avatarColor}}">
<text>{{item.avatarChar}}</text>
</view>
<view class="customer-info">
<text class="customer-name">{{item.name}}</text>
<text class="customer-detail">{{item.count}}次 · {{item.hours}}h · {{item.income}}</text>
</view>
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
</view>
</view>
<view class="toggle-btn" hover-class="toggle-btn--hover" bindtap="toggleRegularCustomer" wx:if="{{regularCustomers.length > 5}}">
<text>{{regularCustomerExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
</view>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<dev-fab />

View File

@@ -0,0 +1,833 @@
/* pages/performance/performance.wxss — 业绩总览 */
/* ========== 全局设置 ========== */
page {
background-color: #f3f3f3;
line-height: 1.5;
}
view {
line-height: inherit;
}
/* ========== 加载态 / 空态 / 错误态 ========== */
.page-loading,
.page-empty,
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text,
.error-text {
font-size: 26rpx;
color: #a6a6a6;
line-height: 36rpx;
}
.retry-btn {
padding: 16rpx 48rpx;
background-color: #0052d9;
color: #ffffff;
border-radius: 16rpx;
font-size: 26rpx;
line-height: 36rpx;
}
.retry-btn--hover {
opacity: 0.7;
}
/* ========== Banner 区域 ========== */
/* 方案SVG 做渐变底图(与 task-detail 保持一致)
* 特点:通过 WXSS 可灵活调整位置、高度、缩放
*/
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
height: 380rpx;
}
/* 渐变底图 — SVG 包含渐变 + 光晕 */
/* 方案SVG 图片完整显示,通过调整位置和尺寸来控制显示区域
* 超出 banner-section 的部分会被 overflow: hidden 裁剪
* 调整方式:
* - 修改 top/left 来移动图片位置选择显示SVG的哪个部分
* - 修改 width/height 来缩放图片
* - 负的 top 值:图片向上移,显示下半部分
* - 正的 top 值:图片向下移,显示上半部分
*/
.banner-bg-img {
position: absolute;
top: -50rpx; /* 调整此值移动图片:负值向上移,正值向下移 */
left: 0; /* 调整此值左右移动图片 */
width: 100%; /* 调整此值缩放图片宽度 */
height: auto; /* 保持图片比例,或设置具体值如 600rpx 来缩放 */
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 40rpx;
height: 100%;
display: flex;
flex-direction: column;
}
/* 自定义导航栏 */
.custom-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin-bottom: 16rpx;
}
.nav-back {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.nav-back--hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
line-height: 44rpx;
}
.nav-placeholder {
width: 44px;
}
/* 个人信息 */
.coach-info {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 40rpx;
}
.coach-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-img {
width: 100%;
height: 100%;
}
.coach-meta {
flex: 1;
}
.coach-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.coach-name {
font-size: 40rpx;
font-weight: 600;
color: #ffffff;
line-height: 56rpx;
}
.coach-role-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
font-size: 22rpx;
color: #ffffff;
line-height: 29rpx;
}
.coach-store {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 36rpx;
}
/* 收入概览卡片 */
.income-overview {
display: flex;
gap: 24rpx;
}
.income-card {
flex: 1;
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
padding: 30rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.income-label {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 8rpx;
line-height: 29rpx;
}
.income-value {
display: block;
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
line-height: 58rpx;
}
.income-highlight {
color: #a7f3d0;
}
/* ========== 通用 Section 卡片 ========== */
.section-card {
background: #ffffff;
border-radius: 32rpx;
padding: 32rpx;
margin: 24rpx 24rpx 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.section-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
font-size: 28rpx;
font-weight: 600;
color: #242424;
line-height: 40rpx;
}
.title-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.dot-primary { background: #0052d9; }
.dot-success { background: #00a870; }
.dot-cyan { background: #06b6d4; }
.dot-pink { background: #ec4899; }
/* ========== 收入档位 ========== */
.tier-card {
position: relative;
padding: 24rpx;
border-radius: 24rpx;
margin-bottom: 20rpx;
}
.tier-current {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 2rpx solid #86efac;
}
.tier-next {
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
border: 2rpx solid #fde047;
}
.tier-badge {
position: absolute;
top: -16rpx;
right: 24rpx;
padding: 4rpx 20rpx;
border-radius: 20rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
line-height: 29rpx;
}
.badge-current {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.badge-next {
background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%);
}
.tier-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.tier-icon-label {
display: flex;
align-items: center;
gap: 12rpx;
}
.tier-emoji {
font-size: 48rpx;
line-height: 1;
}
.tier-label {
font-size: 26rpx;
font-weight: 500;
line-height: 36rpx;
}
.tier-label-green { color: #15803d; }
.tier-label-yellow { color: #a16207; }
.tier-rates {
display: flex;
align-items: center;
gap: 24rpx;
}
.rate-item {
text-align: center;
width: 128rpx;
}
.rate-value-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4rpx;
}
.rate-value {
font-size: 36rpx;
font-weight: 700;
line-height: 51rpx;
}
.rate-unit {
font-size: 22rpx;
line-height: 29rpx;
}
.rate-desc {
font-size: 20rpx;
margin-top: 4rpx;
display: block;
line-height: 29rpx;
}
.rate-green { color: #15803d; }
.rate-green-light { color: #16a34a; }
.rate-yellow { color: #a16207; }
.rate-yellow-light { color: #ca8a04; }
.rate-divider {
width: 2rpx;
height: 64rpx;
background: #bbf7d0;
}
.rate-divider-yellow {
background: #fef08a;
}
/* 升级提示 */
.upgrade-hint {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(to right, #eff6ff, #eef2ff);
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid #bfdbfe;
margin-top: 20rpx;
}
.upgrade-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.upgrade-emoji {
font-size: 48rpx;
line-height: 1;
}
.upgrade-text {
display: flex;
flex-direction: column;
}
.upgrade-label {
font-size: 22rpx;
color: #5e5e5e;
display: block;
line-height: 29rpx;
}
.upgrade-hours {
font-size: 26rpx;
font-weight: 600;
color: #1d4ed8;
line-height: 36rpx;
}
.upgrade-hours-num {
font-size: 30rpx;
line-height: 44rpx;
}
.upgrade-bonus {
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(to right, #fbbf24, #f97316);
color: #ffffff;
padding: 12rpx 24rpx;
border-radius: 16rpx;
}
.bonus-label {
font-size: 20rpx;
opacity: 0.9;
line-height: 29rpx;
}
.bonus-value {
font-size: 36rpx;
font-weight: 700;
line-height: 51rpx;
}
/* ========== 本月业绩明细 ========== */
.income-list {
margin-bottom: 16rpx;
}
.income-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 2rpx solid #f3f3f3;
}
.income-row:last-child {
border-bottom: none;
}
.income-row-left {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
}
.income-icon-box {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background: #f3f3f3;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
flex-shrink: 0;
}
.income-info {
display: flex;
flex-direction: column;
}
.income-item-label {
font-size: 26rpx;
font-weight: 500;
color: #242424;
line-height: 36rpx;
}
.income-item-desc {
font-size: 22rpx;
color: #c5c5c5;
margin-top: 4rpx;
line-height: 29rpx;
}
.income-item-value {
font-size: 30rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
line-height: 44rpx;
}
.income-total {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16rpx;
border-top: 2rpx solid #f3f3f3;
}
.total-label {
font-size: 26rpx;
font-weight: 500;
color: #8b8b8b;
line-height: 36rpx;
}
.total-value {
font-size: 40rpx;
font-weight: 700;
color: #00a870;
font-variant-numeric: tabular-nums;
line-height: 58rpx;
}
/* ========== 服务记录 ========== */
.service-records-section {
margin-top: 32rpx;
margin: 32rpx -32rpx -32rpx;
padding: 32rpx;
background: rgba(243, 243, 243, 0.7);
border-radius: 0 0 32rpx 32rpx;
}
.service-records-header {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.service-records-emoji {
font-size: 26rpx;
line-height: 1;
}
.service-records-title {
font-size: 26rpx;
font-weight: 600;
color: #242424;
line-height: 36rpx;
}
.date-divider {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 0 8rpx;
}
.dd-date {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 500;
line-height: 29rpx;
}
.dd-line {
flex: 1;
height: 2rpx;
background: #dcdcdc;
}
.dd-stats {
font-size: 22rpx;
color: #a6a6a6;
font-variant-numeric: tabular-nums;
line-height: 29rpx;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
cursor: pointer;
}
.record-item:active {
opacity: 0.7;
}
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
flex-shrink: 0;
}
/* 头像渐变色 */
.avatar-from-blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar-from-pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar-from-teal { background: linear-gradient(135deg, #2dd4bf, #10b981); }
.avatar-from-green { background: linear-gradient(135deg, #4ade80, #14b8a6); }
/* 头像渐变色由 app.wxss 全局 .avatar-{key} 统一提供VI §8 */
.avatar-from-purple { background: linear-gradient(135deg, #c084fc, #8b5cf6); }
.avatar-from-violet { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
.avatar-from-amber { background: linear-gradient(135deg, #fbbf24, #eab308); }
.record-content {
flex: 1;
min-width: 0;
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.record-name-time {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.record-name {
font-size: 26rpx;
font-weight: 500;
color: #242424;
flex-shrink: 0;
line-height: 36rpx;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-hours {
font-size: 26rpx;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
line-height: 36rpx;
}
.record-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.record-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.course-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
line-height: 29rpx;
}
.tag-basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
background: #fffbeb;
color: #a16207;
}
.record-location {
font-size: 22rpx;
color: #8b8b8b;
line-height: 29rpx;
}
.record-income {
font-size: 22rpx;
color: #c5c5c5;
flex-shrink: 0;
line-height: 29rpx;
}
.record-income-val {
font-weight: 500;
color: #5e5e5e;
}
.records-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 16rpx 0;
font-size: 26rpx;
color: #8b8b8b;
line-height: 36rpx;
}
.records-view-all {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 12rpx 0;
font-size: 26rpx;
color: #0052d9;
font-weight: 500;
line-height: 36rpx;
}
/* ========== 新客 / 常客列表 ========== */
.customer-list {
margin-bottom: 8rpx;
}
.customer-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 2rpx solid #f3f3f3;
}
.customer-item:last-child {
border-bottom: none;
}
.customer-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
flex-shrink: 0;
}
.customer-info {
flex: 1;
min-width: 0;
}
.customer-name {
display: block;
font-size: 26rpx;
font-weight: 500;
color: #242424;
line-height: 36rpx;
}
.customer-detail {
display: block;
font-size: 22rpx;
color: #a6a6a6;
margin-top: 4rpx;
line-height: 29rpx;
}
.customer-item--hover {
background-color: #f3f3f3;
}
/* ========== 服务记录按钮 ========== */
/* 并列按钮容器 */
.records-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 16rpx 120rpx;
}
.records-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 26rpx;
color: #0052d9;
line-height: 36rpx;
flex: 1;
font-weight: 500;
}
.records-view-all {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
font-size: 26rpx;
color: #0052d9;
font-weight: 500;
line-height: 36rpx;
flex: 1;
}
.toggle-btn--hover {
opacity: 0.6;
}
.view-all--hover {
opacity: 0.6;
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 12rpx 0;
font-size: 26rpx;
color: #0052d9;
line-height: 36rpx;
}
.toggle-btn--hover {
opacity: 0.6;
}
.view-all--hover {
opacity: 0.6;
}

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "审核状态",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,99 @@
import { request } from "../../utils/request"
Page({
data: {
statusBarHeight: 0,
loading: true,
status: "pending" as "pending" | "rejected",
application: null as null | {
id: number
site_code: string
role_type: string
phone: string
status: string
reject_reason: string | null
created_at: string
},
},
onLoad() {
const { statusBarHeight } = wx.getWindowInfo()
this.setData({ statusBarHeight })
this.fetchStatus()
},
onShow() {
this.fetchStatus()
},
onPullDownRefresh() {
this.fetchStatus().finally(() => {
wx.stopPullDownRefresh()
})
},
async fetchStatus() {
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
// 同步 globalData 和 Storage
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
status: data.status,
nickname: data.nickname,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.status)
// 已通过 → 跳转主页
if (data.status === "approved") {
wx.reLaunch({ url: "/pages/mvp/mvp" })
return
}
// 已禁用 → 跳转无权限页
if (data.status === "disabled") {
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
return
}
// 已拒绝 → 跳转无权限页
if (data.status === "rejected") {
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
return
}
// 全新用户(尚未提交申请)→ 跳回申请页
if (data.status === "new") {
wx.reLaunch({ url: "/pages/apply/apply" })
return
}
this.setData({
loading: false,
status: data.status,
application: data.latest_application || null,
})
} catch (err: any) {
this.setData({ loading: false })
wx.showToast({ title: "获取状态失败", icon: "none" })
}
},
onSwitchAccount() {
const app = getApp<IAppOption>()
app.globalData.token = undefined
app.globalData.refreshToken = undefined
app.globalData.authUser = undefined
wx.removeStorageSync("token")
wx.removeStorageSync("refreshToken")
wx.removeStorageSync("userId")
wx.removeStorageSync("userStatus")
wx.reLaunch({ url: "/pages/login/login" })
},
})

View File

@@ -0,0 +1,116 @@
<!-- pages/reviewing/reviewing.wxml — 按 H5 原型结构迁移 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 十字纹背景图案H5 bg-pattern -->
<view class="bg-pattern"></view>
<!-- 顶部渐变装饰 -->
<view class="top-gradient top-gradient--{{status}}"></view>
<!-- 加载中 -->
<view class="content content--loading" wx:if="{{loading}}">
<view class="loading-box">
<t-loading theme="circular" size="42rpx" text="加载中..." />
</view>
</view>
<!-- 主体内容 -->
<view class="content" wx:else>
<!-- 图标区域 -->
<view class="icon-area float-animation">
<!-- 背景光晕 -->
<view class="icon-glow icon-glow--{{status}}"></view>
<!-- 主图标 -->
<view class="icon-box icon-box--{{status}}">
<image wx:if="{{status === 'pending'}}" class="icon-main-img" src="/assets/icons/icon-clock-circle.svg" mode="aspectFit" />
<t-icon wx:else name="close-circle" size="98rpx" color="#fff" />
</view>
<!-- 装饰点 -->
<view class="icon-dot icon-dot--{{status}} dot-1 pulse-soft"></view>
<view class="icon-dot icon-dot--{{status}} dot-2 pulse-soft"></view>
</view>
<!-- 标题区域 -->
<view class="title-area">
<text class="main-title">{{status === 'pending' ? '申请审核中' : '申请未通过'}}</text>
<text class="sub-title" wx:if="{{status === 'pending'}}">您的访问申请已提交成功,正在等待管理员审核,请耐心等待</text>
<text class="sub-title" wx:else>很抱歉,您的申请未通过审核</text>
</view>
<!-- 进度提示卡片(仅审核中显示) -->
<view class="progress-card" wx:if="{{status === 'pending'}}">
<view class="progress-header">
<view class="progress-icon-box">
<t-icon name="info-circle-filled" size="35rpx" color="#ed7b2f" />
</view>
<view class="progress-header-text">
<text class="progress-title">审核进度</text>
<text class="progress-desc">通常需要 1-3 个工作日</text>
</view>
</view>
<!-- 进度步骤 -->
<view class="progress-steps">
<view class="step-group">
<view class="step-dot step-dot--done">
<t-icon name="check" size="28rpx" color="#fff" />
</view>
<text class="step-label step-label--done">已提交</text>
</view>
<view class="step-line">
<view class="step-line-fill"></view>
</view>
<view class="step-group">
<view class="step-dot step-dot--active">
<view class="step-dot-inner pulse-soft"></view>
</view>
<text class="step-label step-label--active">审核中</text>
</view>
<view class="step-line"></view>
<view class="step-group">
<view class="step-dot step-dot--pending"></view>
<text class="step-label step-label--pending">通过</text>
</view>
</view>
</view>
<!-- 拒绝原因卡片 -->
<view class="reject-card" wx:if="{{status === 'rejected' && application && application.reject_reason}}">
<view class="reject-header">
<t-icon name="error-circle-filled" size="32rpx" color="#e34d59" />
<text class="reject-title">拒绝原因</text>
</view>
<text class="reject-reason">{{application.reject_reason}}</text>
</view>
<!-- 申请信息摘要 -->
<view class="info-card" wx:if="{{application}}">
<text class="info-card-title">申请信息</text>
<view class="info-row">
<text class="info-label">球房ID</text>
<text class="info-value">{{application.site_code}}</text>
</view>
<view class="info-row">
<text class="info-label">申请身份</text>
<text class="info-value">{{application.role_type}}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-value">{{application.phone}}</text>
</view>
</view>
<!-- 联系提示 -->
<view class="contact-hint">
<t-icon name="chat" size="28rpx" color="#a6a6a6" />
<text class="contact-text">如有疑问,请联系管理员</text>
</view>
</view>
<!-- 底部按钮区域 -->
<view class="bottom-area" wx:if="{{!loading}}">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
</view>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,427 @@
/* pages/reviewing/reviewing.wxss — 按 H5 原型精确转换 */
/* 一屏页面height + box-sizing 确保 padding 不增加总高度 */
.page {
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
background-color: #f8fafc;
position: relative;
}
/* H5: bg-pattern 十字纹背景 — 用 WXML view + repeating-linear-gradient 模拟 */
/* 背景纹理间距不缩放,保持原值 */
.bg-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-linear-gradient(0deg, transparent, transparent 58rpx, rgba(0,82,217,0.03) 58rpx, rgba(0,82,217,0.03) 62rpx, transparent 62rpx, transparent 120rpx),
repeating-linear-gradient(90deg, transparent, transparent 58rpx, rgba(0,82,217,0.03) 58rpx, rgba(0,82,217,0.03) 62rpx, transparent 62rpx, transparent 120rpx);
z-index: 0;
pointer-events: none;
}
/* ---- 顶部渐变装饰 ---- */
.top-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 448rpx;
z-index: 0;
}
/* H5: from-warning/10 = rgba(237,123,47,0.10) */
.top-gradient--pending {
background: linear-gradient(to bottom, rgba(237, 123, 47, 0.10), transparent);
}
.top-gradient--rejected {
background: linear-gradient(to bottom, rgba(227, 77, 89, 0.1), transparent);
}
/* ---- 主体内容 ---- */
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 56rpx;
position: relative;
z-index: 1;
box-sizing: border-box;
}
.content--loading {
justify-content: flex-start;
padding-top: 350rpx;
}
/* ---- 加载中 ---- */
.loading-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* ---- 图标区域 ---- */
.icon-area {
position: relative;
margin-bottom: 56rpx;
}
/* H5: w-32 h-32 bg-warning/20 rounded-full blur-xl — 小程序不支持 filter:blur用更大尺寸 + radial-gradient 模拟 */
.icon-glow {
position: absolute;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
top: -42rpx;
left: -42rpx;
}
.icon-glow--pending {
background: radial-gradient(circle, rgba(237, 123, 47, 0.18) 0%, rgba(237, 123, 47, 0.06) 50%, transparent 75%);
}
.icon-glow--rejected {
background: radial-gradient(circle, rgba(227, 77, 89, 0.18) 0%, rgba(227, 77, 89, 0.06) 50%, transparent 75%);
}
/* H5: w-28 h-28 = 112px = 224rpx, rounded-3xl = 48rpx */
.icon-box {
position: relative;
width: 196rpx;
height: 196rpx;
border-radius: 42rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* H5: bg-gradient-to-br from-amber-400 to-orange-500, shadow-xl shadow-warning/30 */
.icon-box--pending {
background: linear-gradient(135deg, #f59e0b, #f97316);
box-shadow: 0 14rpx 42rpx rgba(237, 123, 47, 0.3);
}
.icon-box--rejected {
background: linear-gradient(135deg, #f87171, #e34d59);
box-shadow: 0 14rpx 42rpx rgba(227, 77, 89, 0.3);
}
/* 主图标图片 — H5: w-14 h-14 = 56px = 112rpx */
.icon-main-img {
width: 98rpx;
height: 98rpx;
}
.icon-dot {
position: absolute;
border-radius: 50%;
}
/* dot-1: bg-warning = #ed7b2f; dot-2: bg-amber-300 = #fcd34d */
.icon-dot--pending.dot-1 { background: #ed7b2f; }
.icon-dot--pending.dot-2 { background: #fcd34d; }
.icon-dot--rejected { background: #e34d59; }
/* H5: -top-2 -right-2 w-4 h-4 = 32rpx */
.dot-1 {
width: 28rpx;
height: 28rpx;
top: -14rpx;
right: -14rpx;
}
/* H5: -bottom-1 -left-3 w-3 h-3 = 24rpx, opacity 0.7, animation-delay 0.5s */
.dot-2 {
width: 22rpx;
height: 22rpx;
bottom: -8rpx;
left: -22rpx;
opacity: 0.7;
animation-delay: 0.5s;
}
/* ---- 标题区域 ---- */
/* H5: text-center mb-8 = 64rpx */
.title-area {
text-align: center;
margin-bottom: 56rpx;
}
/* H5: text-2xl = 48rpx, font-bold = 700, text-gray-10 = #4b4b4b, mb-3 = 24rpx */
.main-title {
display: block;
font-size: 42rpx;
font-weight: 700;
color: #4b4b4b;
margin-bottom: 22rpx;
}
/* H5: text-sm = 28rpx, text-gray-7 = #8b8b8b, leading-relaxed = 1.625, max-w-xs ≈ 640rpx */
.sub-title {
display: block;
font-size: 24rpx;
color: #8b8b8b;
line-height: 1.625;
max-width: 560rpx;
margin: 0 auto;
}
/* ---- 进度提示卡片 ---- */
/* H5: w-full max-w-sm bg-white rounded-2xl p-5 shadow-lg shadow-gray-200/50 mb-6 */
.progress-card {
width: 100%;
max-width: 482rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 36rpx;
margin-bottom: 42rpx;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.06);
}
/* H5: flex items-center gap-4 = 32rpx mb-4 = 32rpx */
.progress-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 28rpx;
margin-bottom: 28rpx;
}
/* H5: w-10 h-10 = 80rpx, bg-warning/10, rounded-xl = 24rpx */
.progress-icon-box {
width: 70rpx;
height: 70rpx;
min-width: 70rpx;
background: rgba(237, 123, 47, 0.1);
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.progress-header-text {
display: flex;
flex-direction: column;
}
/* H5: text-sm = 28rpx, font-medium = 500, text-gray-13 = #242424 */
.progress-title {
font-size: 24rpx;
font-weight: 500;
color: #242424;
margin-bottom: 4rpx;
}
/* H5: text-xs = 24rpx, text-gray-6 = #a6a6a6 */
.progress-desc {
font-size: 22rpx;
color: #a6a6a6;
}
/* ---- 进度步骤 ---- */
/* H5: flex items-center gap-3 = 24rpx */
.progress-steps {
display: flex;
flex-direction: row;
align-items: center;
gap: 22rpx;
}
.step-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 14rpx;
flex-shrink: 0;
}
/* H5: w-6 h-6 = 48rpx */
.step-dot {
width: 42rpx;
height: 42rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-dot--done { background: #00a870; }
.step-dot--active { background: rgba(237, 123, 47, 0.2); }
.step-dot--pending { background: #f3f3f3; }
/* H5: w-2 h-2 = 16rpx */
.step-dot-inner {
width: 14rpx;
height: 14rpx;
background: #ed7b2f;
border-radius: 50%;
}
/* H5: text-xs = 24rpx */
.step-label {
font-size: 22rpx;
color: #c5c5c5;
white-space: nowrap;
}
.step-label--done { color: #5e5e5e; }
.step-label--active { color: #a6a6a6; }
.step-label--pending { color: #c5c5c5; }
/* H5: flex-1 h-0.5 = 4rpx, bg-gray-200 = #eeeeee, gap-3 = 24rpx 两侧 */
.step-line {
flex: 1;
height: 4rpx;
background: #eeeeee;
border-radius: 2rpx;
overflow: hidden;
}
/* H5: w-1/2 bg-warning */
.step-line-fill {
width: 50%;
height: 100%;
background: #ed7b2f;
border-radius: 2rpx;
}
/* ---- 拒绝原因卡片 ---- */
.reject-card {
width: 100%;
background: #fff5f5;
border: 2rpx solid rgba(227, 77, 89, 0.15);
border-radius: 28rpx;
padding: 28rpx;
margin-bottom: 22rpx;
}
.reject-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 14rpx;
}
.reject-title {
font-size: 24rpx;
font-weight: 500;
color: #e34d59;
}
.reject-reason {
font-size: 22rpx;
color: #5e5e5e;
line-height: 1.625;
}
/* ---- 申请信息卡片 ---- */
.info-card {
width: 100%;
background: #ffffff;
border-radius: 28rpx;
padding: 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
}
.info-card-title {
display: block;
font-size: 24rpx;
font-weight: 500;
color: #242424;
margin-bottom: 22rpx;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14rpx 0;
border-bottom: 2rpx solid #f5f5f5;
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: 24rpx;
color: #8b8b8b;
}
.info-value {
font-size: 24rpx;
color: #242424;
font-weight: 500;
}
/* ---- 联系提示 ---- */
/* H5: flex items-center gap-2 text-gray-6 mb-8 */
.contact-hint {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 56rpx;
}
/* H5: text-xs = 24rpx */
.contact-text {
font-size: 22rpx;
color: #a6a6a6;
}
/* ---- 底部按钮区域 ---- */
/* H5: px-8 = 64rpx, pb-12 = 96rpx */
.bottom-area {
padding: 0 56rpx 84rpx;
position: relative;
z-index: 1;
}
/* 原生按钮替代 TDesign — H5: w-full py-3.5=56rpx bg-white border border-gray-200 rounded-xl */
.switch-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
padding: 24rpx 0;
background: #ffffff;
border: 2rpx solid #eeeeee;
border-radius: 22rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.switch-btn-text {
font-size: 24rpx;
font-weight: 500;
color: #5e5e5e;
}
/* ---- 动画 ---- */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-18rpx); }
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
@keyframes pulse-soft {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,18 @@
{
"navigationBarTitleText": "任务详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"note-modal": "/components/note-modal/note-modal",
"abandon-modal": "/components/abandon-modal/abandon-modal",
"star-rating": "/components/star-rating/star-rating",
"heart-icon": "/components/heart-icon/heart-icon",
"ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon",
"ai-title-badge": "/components/ai-title-badge/ai-title-badge",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"clue-card": "/components/clue-card/clue-card",
"service-record-card": "/components/service-record-card/service-record-card",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,411 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time'
import { formatMoney } from '../../utils/money'
/** 维客线索项 */
interface RetentionClue {
tag: string
tagColor: 'primary' | 'success' | 'purple' | 'error'
emoji: string
text: string
source: string
desc?: string
expanded?: boolean
}
/** 服务记录项 */
interface ServiceRecord {
table: string
type: string
typeClass: 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
/** 卡片类型course=普通课recharge=充值提成 */
recordType?: 'course' | 'recharge'
duration: number // 折算后课时小时number
durationRaw?: number // 折算前课时小时number可选
income: number // 收入(元,整数)
/** 是否预估金额 */
isEstimate?: boolean
drinks: string
date: string
}
/** 服务汇总 */
interface ServiceSummary {
totalHours: number
totalIncome: number
avgIncome: number
}
/**
* 将 ISO/空格分隔的日期字符串格式化为中文短格式
* "2026-02-07T21:30" → "2月7日 21:30"
* 与 customer-service-records 页面拼接方式保持一致
*/
function formatServiceDate(dateStr: string): string {
if (!dateStr) return dateStr
// 兼容 "2026-02-07T21:30" 和 "2026-02-07 21:30"
const normalized = dateStr.replace('T', ' ')
const [datePart, timePart] = normalized.split(' ')
if (!datePart) return dateStr
const parts = datePart.split('-')
if (parts.length < 3) return dateStr
const month = parseInt(parts[1], 10)
const day = parseInt(parts[2], 10)
return timePart ? `${month}${day}${timePart}` : `${month}${day}`
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
detail: null as TaskDetail | null,
sortedNotes: [] as Note[],
noteModalVisible: false,
// --- 维客线索 ---
retentionClues: [
{ tag: '客户\n基础', tagColor: 'primary', emoji: '🎂', text: '生日 3月15日 · VIP会员 · 注册2年', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🌙', text: '常来夜场 · 月均4-5次', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '💰', text: '高客单价', source: 'By:系统', desc: '近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%', expanded: false },
{ tag: '玩法\n偏好', tagColor: 'purple', emoji: '🎱', text: '偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣', source: 'By:系统', desc: '中式八球占比 60%,斯诺克 30%近2周开始尝试花式九球技术水平中等偏上', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '⚠️', text: '上次提到想练斯诺克走位', source: 'By:小燕', desc: '2月7日到店时主动提及希望有针对性的走位训练建议下次安排斯诺克专项课程', expanded: false },
{ tag: '社交\n偏好', tagColor: 'purple', emoji: '👥', text: '喜欢带朋友来玩 · 社交型客户', source: 'By:系统', desc: '70%的到店记录都是多人消费,经常介绍新客户;建议推荐团建套餐和会员推荐奖励', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🍷', text: '酒水消费占比高 · 偏好高端酒水', source: 'By:系统', desc: '每次到店必点酒水偏好芝华士、百威等品牌酒水消费占总消费的40%', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '💬', text: '上次提到想办生日派对', source: 'By:Lucy', desc: '3月15日生日想在店里办派对预计10-15人已记录需求建议提前联系确认', expanded: false },
] as RetentionClue[],
// --- 话术参考 ---
talkingPoints: [
'王哥您好,好久不见!最近店里新到了几张国际标准的斯诺克球桌,知道您是斯诺克爱好者,想邀请您有空来体验一下~',
'王哥,最近忙吗?这周末我们有个老客户专属的球友交流赛,奖品还挺丰富的,您要不要来参加?',
'王哥好呀,上次您提到想练练斯诺克的走位,我最近研究了一些新的训练方法,下次来的时候可以一起试试~',
'王哥,好久没见您了,您的老位置 A12 号台一直给您留着呢!最近晚上人不多,环境特别好,随时欢迎您来~',
'王哥您好,我们这个月推出了储值会员专属的夜场优惠套餐,包含球台+酒水,性价比很高,给您留意着呢~',
] as string[],
copiedIndex: -1,
// --- 近期服务记录 ---
serviceSummary: { totalHours: 6.0, totalIncome: 510, avgIncome: 170 } as ServiceSummary,
serviceRecords: [
{ table: 'A12号台', type: '基础课', typeClass: 'basic', duration: 2.5, durationRaw: 3.0, income: 200, isEstimate: true, drinks: '🍷 百威x2 红牛x1', date: formatServiceDate('2026-02-07T21:30') },
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: 2.0, durationRaw: 2.0, income: 160, isEstimate: false, drinks: '🍷 可乐x1', date: formatServiceDate('2026-02-01T20:30') },
{ table: 'VIP1号房', type: '包厢课', typeClass: 'vip', duration: 1.5, durationRaw: 1.5, income: 150, isEstimate: true, drinks: '🍷 芝华士x1 矿泉水x2', date: formatServiceDate('2026-01-28T19:00') },
{ table: '', type: '充值', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 80, isEstimate: false, drinks: '', date: formatServiceDate('2026-01-15T10:00') },
] as ServiceRecord[],
// --- 放弃弹窗 ---
abandonModalVisible: false,
// --- 手机号显示 ---
phoneVisible: false,
// --- 储值等级 ---
storageLevel: '非常多',
// --- 关系等级相关 ---
relationLevel: 'excellent' as 'poor' | 'normal' | 'good' | 'excellent',
relationLevelText: '很好',
relationColor: '#e91e63',
// --- Banner 背景(根据任务类型动态切换)---
bannerBgSvg: '/assets/images/banner-bg-red-aurora.svg',
// --- 调试面板 ---
showDebugPanel: false,
debugTaskType: 'high_priority',
debugHeartScore: 8.5,
debugShowExpandBtn: true, // 调试:备注弹窗是否显示展开/收起按钮
// --- AI 配色 ---
aiColor: 'indigo' as 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple',
},
onLoad(options: { id?: string }) {
const id = options?.id || ''
// 随机 AI 配色
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
this.setData({ aiColor })
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0]
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (detail.taskType === 'high_priority') {
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
} else if (detail.taskType === 'priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
} else if (detail.taskType === 'relationship') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (detail.taskType === 'callback') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}
// 添加更多 mock 备注
const mockNotes: Note[] = [
{ id: 'note-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-10T16:30', score: 10 },
{ id: 'note-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-05T14:20', score: 7.5 },
{ id: 'note-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-28T18:45', score: 6 },
{ id: 'note-4', content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-20T21:15', score: 9.5 },
{ id: 'note-5', content: '完成高优先召回任务。客户反馈最近工作太忙,这周末会来店里。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-15T10:30', score: 8 },
]
// 附加 timeLabel 字段
const notesWithLabel = mockNotes.map((n) => ({
...n,
timeLabel: formatRelativeTime(n.createdAt),
}))
const sorted = sortByTimestamp(notesWithLabel) as (Note & { timeLabel: string })[]
this.updateRelationshipDisplay(detail.heartScore)
this.setData({
pageState: 'normal',
detail,
sortedNotes: sorted,
debugHeartScore: detail.heartScore,
bannerBgSvg,
})
} catch (_e) {
this.setData({ pageState: 'error' })
}
}, 500)
},
/** 更新关系等级显示 */
updateRelationshipDisplay(score: number) {
let level: 'poor' | 'normal' | 'good' | 'excellent' = 'poor'
let text = '待发展'
let color = '#64748b' // 蓝灰色,区别于蓝色爱心
if (score > 8.5) {
level = 'excellent'
text = '很好'
color = '#e91e63' // 深粉色,区别于粉红爱心
} else if (score >= 6) {
level = 'good'
text = '良好'
color = '#ea580c' // 深橙色,区别于橙色爱心
} else if (score >= 3.5) {
level = 'normal'
text = '一般'
color = '#eab308' // 金黄色,区别于黄色爱心
}
this.setData({
relationLevel: level,
relationLevelText: text,
relationColor: color,
})
},
/** 返回 */
onBack() {
wx.navigateBack()
},
/** 重试 */
onRetry() {
const id = this.data.detail?.id || ''
this.loadData(id)
},
/** 查看/隐藏手机号 */
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
onCopyPhone() {
const phone = '13812345678'
wx.setClipboardData({
data: phone,
success: () => {
wx.showToast({ title: '手机号码已复制', icon: 'none' })
},
})
},
/** 展开/收起维客线索描述 */
onToggleClue(e: WechatMiniprogram.BaseEvent) {
const idx = e.currentTarget.dataset.index as number
const key = `retentionClues[${idx}].expanded`
const current = this.data.retentionClues[idx]?.expanded || false
this.setData({ [key]: !current })
},
/** 复制话术 */
onCopySpeech(e: WechatMiniprogram.BaseEvent) {
const idx = e.currentTarget.dataset.index as number
const text = this.data.talkingPoints[idx]
if (!text) return
wx.setClipboardData({
data: text,
success: () => {
this.setData({ copiedIndex: idx })
setTimeout(() => this.setData({ copiedIndex: -1 }), 2000)
},
})
},
/** 打开备注弹窗 */
onAddNote() {
this.setData({ noteModalVisible: true })
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { content } = e.detail
wx.showToast({ title: '备注已保存', icon: 'success' })
this.setData({ noteModalVisible: false })
const newNote: Note = {
id: `note-${Date.now()}`,
content,
tagType: 'customer',
tagLabel: `客户:${this.data.detail?.customerName || ''}`,
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
this.setData({ sortedNotes: [newNote, ...this.data.sortedNotes] })
},
/** 备注弹窗取消 */
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** 删除备注 */
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
const noteId = e.currentTarget.dataset.id as string
wx.showModal({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
if (res.confirm) {
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
this.setData({ sortedNotes: notes })
wx.showToast({ title: '已删除', icon: 'success' })
}
},
})
},
/** 放弃/取消放弃 */
onAbandon() {
// 如果已放弃,直接取消放弃(不需要二次确认)
if (this.data.detail?.status === 'abandoned') {
this.cancelAbandon()
return
}
// 否则打开放弃弹窗
this.setData({ abandonModalVisible: true })
},
/** 取消放弃 - 直接修改状态 */
cancelAbandon() {
wx.showLoading({ title: '处理中...' })
setTimeout(() => {
wx.hideLoading()
// 更新状态为 pending
this.setData({ 'detail.status': 'pending' })
wx.showToast({ title: '已取消放弃', icon: 'success' })
}, 500)
},
/** 放弃 — 确认 */
onAbandonConfirm(_e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
this.setData({ abandonModalVisible: false })
wx.showLoading({ title: '处理中...' })
setTimeout(() => {
wx.hideLoading()
// 更新状态为 abandoned
this.setData({ 'detail.status': 'abandoned' })
wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })
}, 500)
},
/** 放弃 — 取消 */
onAbandonCancel() {
this.setData({ abandonModalVisible: false })
},
/** 问问助手 */
onAskAssistant() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看全部服务记录 */
onViewAllRecords() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** noop — 阻止弹窗背景点击冒泡 */
noop() {},
/** 切换调试面板 */
toggleDebugPanel() {
this.setData({ showDebugPanel: !this.data.showDebugPanel })
},
/** 调试 - 切换任务类型 */
onDebugTaskType(e: WechatMiniprogram.BaseEvent) {
const type = e.currentTarget.dataset.type as string
const typeMap: Record<string, string> = {
high_priority: '高优先召回',
priority_recall: '优先召回',
relationship: '关系构建',
callback: '客户回访',
}
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (type === 'high_priority') {
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
} else if (type === 'priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
} else if (type === 'relationship') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (type === 'callback') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}
this.setData({
debugTaskType: type,
'detail.taskTypeLabel': typeMap[type] || '高优先召回',
bannerBgSvg,
})
},
/** 调试 - 调整关系数值 */
onDebugHeartScore(e: WechatMiniprogram.SliderChange) {
const score = e.detail.value
this.setData({
debugHeartScore: score,
'detail.heartScore': score,
})
this.updateRelationshipDisplay(score)
},
/** 调试 - 切换备注弹窗展开按钮 */
onDebugToggleExpandBtn(e: WechatMiniprogram.BaseEvent) {
const value = e.currentTarget.dataset.value as boolean
this.setData({ debugShowExpandBtn: value })
},
})

View File

@@ -0,0 +1,273 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{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-area">
<image src="{{bannerBgSvg}}" class="banner-bg-svg" mode="scaleToFill" />
<view class="banner-content">
<!-- 放弃/取消放弃按钮 - 右上角 -->
<view class="banner-abandon-btn" bindtap="onAbandon" hover-class="banner-abandon-btn--hover">
<text class="banner-abandon-text">{{detail.status === 'abandoned' ? '取消放弃' : '放弃'}}</text>
</view>
<view class="customer-info">
<view class="avatar-box">
<text class="avatar-text">{{detail.customerName[0] || '?'}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{detail.customerName}}</text>
<text class="task-type-tag">{{detail.taskTypeLabel || '高优先召回'}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? '13812345678' : '138****5678'}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
<text class="storage-level-text">💰 储值 {{storageLevel}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content">
<!-- 与我的关系(动态颜色)-->
<view class="card">
<view class="card-header">
<text class="section-title title-pink">与我的关系</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<view class="relationship-row">
<view class="rel-tag rel-level-{{relationLevel}}">
<heart-icon score="{{detail.heartScore}}" />
<text class="rel-tag-text">{{relationLevelText}}</text>
</view>
<view class="rel-bar">
<view class="rel-bar-fill rel-fill-{{relationLevel}}" style="width: {{detail.heartScore * 10}}%; background: {{relationColor}};"></view>
</view>
<text class="rel-score" style="color: {{relationColor}};">{{fmt.toFixed(detail.heartScore, 1)}}</text>
</view>
<view class="card-desc-wrap">
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
</view>
</view>
<!-- 任务建议 -->
<view class="card">
<view class="card-header">
<text class="section-title title-orange">任务建议</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<view class="suggestion-box">
<view class="suggestion-header">
<text class="suggestion-icon-text">💡 建议执行</text>
</view>
<view class="suggestion-body">
<view class="suggestion-intro-wrap">
<text class="suggestion-intro">该客户已有 15 天未到店,存在流失风险。建议通过微信联系:</text>
</view>
<view class="suggestion-item" wx:for="{{detail.aiAnalysis.suggestions}}" wx:key="index">
<text class="suggestion-item-text">• {{item}}</text>
</view>
</view>
</view>
<view class="speech-section">
<text class="speech-title">💬 话术参考</text>
<view class="speech-list">
<view class="speech-bubble" wx:for="{{talkingPoints}}" wx:key="index">
<view class="speech-text-wrap">
<ai-inline-icon color="{{aiColor}}" />
<text class="speech-text">{{item}}</text>
</view>
<view class="speech-copy-row">
<view class="copy-btn" bindtap="onCopySpeech" data-index="{{index}}" hover-class="copy-btn--hover">
<text class="copy-btn-text">{{copiedIndex === index ? '✓ 已复制' : '复制'}}</text>
</view>
</view>
<view class="speech-arrow"></view>
</view>
</view>
</view>
</view>
<!-- 维客线索 -->
<view class="card">
<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="{{retentionClues}}"
wx:key="index"
tag="{{item.tag}}"
category="{{item.tagColor}}"
emoji="{{item.emoji}}"
title="{{item.text}}"
source="{{item.source}}"
content="{{item.desc || ''}}"
/>
</view>
</view>
<!-- 我给TA的备注 -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">我给TA的备注</text>
<text class="note-count">{{sortedNotes.length}} 条备注</text>
</view>
<block wx:if="{{sortedNotes.length > 0}}">
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<view class="note-date-wrap">
<text class="note-date">{{item.timeLabel || item.createdAt}}</text>
</view>
<view class="note-top-right">
<star-rating score="{{item.score || 3}}" size="28rpx" readonly="{{true}}" />
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="32rpx" color="#a6a6a6" />
</view>
</view>
</view>
<view class="note-content-wrap">
<text class="note-content">{{item.content}}</text>
</view>
</view>
</block>
<view class="note-empty" wx:else>
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
<text class="empty-hint">暂无备注</text>
</view>
</view>
<!-- 60天内服务记录 -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">60天内服务记录</text>
<text class="note-count">共 {{serviceRecords.length}} 次</text>
</view>
<!-- 汇总统计 -->
<view class="svc-summary">
<view class="svc-summary-item svc-summary-blue">
<view class="svc-summary-value-row">
<text class="svc-summary-value svc-val-blue">{{fmt.hours(serviceSummary.totalHours)}}</text>
</view>
<text class="svc-summary-label">总时长</text>
</view>
<view class="svc-summary-item svc-summary-green">
<text class="svc-summary-value svc-val-green">{{fmt.money(serviceSummary.totalIncome)}}</text>
<text class="svc-summary-label">总收入</text>
</view>
<view class="svc-summary-item svc-summary-orange">
<text class="svc-summary-value svc-val-orange">{{fmt.money(serviceSummary.avgIncome)}}</text>
<text class="svc-summary-label">场均</text>
</view>
</view>
<!-- 记录列表(使用 service-record-card 组件)-->
<view class="svc-list">
<service-record-card
wx:for="{{serviceRecords}}"
wx:key="index"
time="{{item.date}}"
course-label="{{item.type}}"
type-class="{{item.typeClass}}"
type="{{item.recordType || 'course'}}"
table-no="{{item.table}}"
hours="{{item.duration}}"
hours-raw="{{item.durationRaw}}"
drinks="{{item.drinks}}"
income="{{item.income}}"
is-estimate="{{item.isEstimate}}"
/>
</view>
<view class="svc-view-all" bindtap="onViewAllRecords" hover-class="svc-view-all--hover">
<text class="svc-view-all-text">查看全部服务记录 →</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-bar safe-area-bottom">
<view class="btn-ask" bindtap="onAskAssistant" hover-class="btn-ask--hover">
<t-icon name="chat" size="36rpx" color="#ffffff" />
<text class="btn-text">问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
<t-icon name="edit-1" size="36rpx" color="#242424" />
<text class="btn-text-dark">备注</text>
</view>
</view>
<!-- 备注弹窗 -->
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.customerName || ''}}" initialScore="{{0}}" initialContent="" showExpandBtn="{{detail.taskType !== 'callback'}}" showRating="{{true}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
<!-- 放弃弹窗 -->
<abandon-modal
visible="{{abandonModalVisible}}"
customerName="{{detail.customerName || ''}}"
bind:confirm="onAbandonConfirm"
bind:cancel="onAbandonCancel"
/>
<!-- 页面调试工具 -->
<view class="debug-panel" wx:if="{{showDebugPanel}}">
<view class="debug-header">
<text class="debug-title">🔧 页面调试</text>
<view class="debug-close" bindtap="toggleDebugPanel" hover-class="debug-close--hover">
<t-icon name="close" size="32rpx" color="#5e5e5e" />
</view>
</view>
<view class="debug-section">
<text class="debug-label">任务类型:</text>
<view class="debug-btn-group">
<view class="debug-btn {{debugTaskType === 'high_priority' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="high_priority">高优先召回</view>
<view class="debug-btn {{debugTaskType === 'priority_recall' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="priority_recall">优先召回</view>
<view class="debug-btn {{debugTaskType === 'relationship' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="relationship">关系构建</view>
<view class="debug-btn {{debugTaskType === 'callback' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="callback">客户回访</view>
</view>
</view>
<view class="debug-section">
<text class="debug-label">关系数值:{{debugHeartScore}}</text>
<slider class="debug-slider" min="0" max="10" step="0.5" value="{{debugHeartScore}}" bindchange="onDebugHeartScore" activeColor="#ec4899" backgroundColor="#f3f3f3" block-size="24" />
<view class="debug-hint">
<text>0-3.5: 待发展</text>
<text>3.5-6: 一般</text>
<text>6-8.5: 良好</text>
<text>>8.5: 很好</text>
</view>
</view>
</view>
<!-- 调试面板触发按钮 -->
<view class="debug-trigger" bindtap="toggleDebugPanel" hover-class="debug-trigger--hover">
<text>🔧</text>
</view>
</block>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "任务",
"enablePullDownRefresh": true,
"usingComponents": {
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
"heart-icon": "/components/heart-icon/heart-icon",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon",
"note-modal": "/components/note-modal/note-modal",
"abandon-modal": "/components/abandon-modal/abandon-modal",
"dev-fab": "/components/dev-fab/dev-fab",
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,701 @@
/* 任务列表页 — 2026-03-13 全量重写1:1 对齐 H5 原型 task-list.html */
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-13 | 重写 task-list 1:1 还原 H5 | 对齐 H5 四种任务类型、border-left 彩条颜色、圆形红戳、mock 数据贴近原型 |
| 2026-03-13 | banner 错位重做 | 添加 userName/userRole/storeName 到 page data移除 banner 组件引用 |
| 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 |
| 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap标签恢复 CSS 渐变实现) |
*/
import { mockTasks, mockPerformance } from '../../utils/mock-data'
import type { Task } from '../../utils/mock-data'
import { formatMoney } from '../../utils/money'
import { formatDeadline } from '../../utils/time'
/** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail由详情页根据 taskId 动态展示内容 */
const DETAIL_ROUTE = '/pages/task-detail/task-detail'
/* ╔══════════════════════════════════════════════════════╗
* ║ 进度条动画参数 — 在此调节 ║
* ╚══════════════════════════════════════════════════════╝
*
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
*
* ┌─────────────┐ SPARK_DELAY_MS ┌─────────────┐ NEXT_LOOP_DELAY_MS ┌─────────────┐
* │ 高光匀速扫过 │ ───────────────▶ │ 火花迸发 │ ──────────────────▶ │ 下一轮 │
* │ 时长由速度决定│ │ SPARK_DUR_MS│ │(重新读进度) │
* └─────────────┘ └─────────────┘ └─────────────┘
*
* SHINE_SPEED : 高光移动速度,范围 1~100
* 1 = 最慢最宽进度条100%)下 5 秒走完
* 100 = 最快最宽进度条100%)下 0.05 秒走完
* 实际时长 = 基准时长 × (filledPct/100)
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) × (5-0.05)s
*
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
* 正数 = 高光结束后停顿再点亮
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
*
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
*
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
* 正数 = 停顿一段时间
* 负数 = 火花还未消散完,高光已从左端启动
*/
const SHINE_SPEED = 70 // 1~100速度值
const SPARK_DELAY_MS = -200 // 毫秒,高光结束→光柱点亮+火花(负=提前)
const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束→下轮高光(负=提前)
/* 根据速度值和进度百分比计算高光时长
* 高光宽度固定SHINE_WIDTH_RPX需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx750 - 左右padding各58rpx高光宽度约占轨道 19%
* 时长正比于需要走过的总距离,保证视觉速度恒定
*
* 速度1 → baseDur=5000ms最慢速度100 → baseDur=50ms最快
* shineDurMs = baseDur × (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
const SHINE_WIDTH_RPX = 120 // rpx需与 WXSS 的 --shine-width 保持一致
const TRACK_WIDTH_RPX = 634 // rpx进度条轨道宽度750 - padding 116rpx
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ≈19%
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
const baseDur = 5000 - t * (5000 - 50) // ms走完100%进度条所需时长
// 实际距离 = 填充条 + 高光自身,相对于(100% + 高光宽度%)归一化
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
/** 扩展任务字段 */
interface EnrichedTask extends Task {
lastVisitDays: number
balanceLabel: string
aiSuggestion: string
abandonReason?: string
isAbandoned: boolean
deadlineLabel: string
deadlineStyle: 'normal' | 'warning' | 'danger' | 'muted'
}
/** 刻度项 */
interface TickItem {
value: number // 刻度数值(如 100
label: string // 显示文字(如 '100'
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
highlight: boolean // 是否加粗高亮
}
/** Mock: 根据档位节点数组生成刻度数据 */
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2, // 第3个档位如130h高亮可由接口控制
}))
}
/** P0: 业绩进度卡片数据 */
interface PerfData {
nextTierHours: number
remainHours: number
currentTier: number
tierProgress: number
filledPct: number
clampedSparkPct: number
ticks: TickItem[] // 刻度数组由接口传入Mock 时由 buildTicks 生成)
shineDurMs: number
sparkDurMs: number
shineRunning: boolean
sparkRunning: boolean
basicHours: string
bonusHours: string
totalHours: string
tierCompleted: boolean
bonusMoney: string
incomeMonth: string
prevMonth: string
incomeFormatted: string
incomeTrend: string
incomeTrendDir: 'up' | 'down'
}
/** Mock: 为任务附加扩展字段 */
function enrichTask(task: Task): EnrichedTask {
const daysSeed = (task.id.charCodeAt(task.id.length - 1) % 15) + 1
const balanceSeedNum = ((task.id.charCodeAt(task.id.length - 1) * 137) % 5000) + 200
const suggestions = [
'建议推荐斯诺克进阶课程,提升客户粘性',
'客户近期消费下降,建议电话关怀了解原因',
'适合推荐周末球友赛活动,增强社交体验',
'高价值客户建议维护关系并推荐VIP权益',
'新客户首次体验后未续费,建议跟进意向',
]
const suggIdx = task.id.charCodeAt(task.id.length - 1) % suggestions.length
return {
...task,
lastVisitDays: daysSeed,
balanceLabel: formatMoney(balanceSeedNum),
aiSuggestion: suggestions[suggIdx],
isAbandoned: task.status === 'abandoned',
abandonReason: task.status === 'abandoned' ? '客户已转至其他门店' : undefined,
deadlineLabel: formatDeadline((task as any).deadline).text,
deadlineStyle: formatDeadline((task as any).deadline).style,
}
}
/** Mock: 构造业绩进度卡片数据 — 对齐 H5 原型数值 */
function buildPerfData(): PerfData {
const total = 87.5
const filledPct = Math.min(100, parseFloat(((total / 220) * 100).toFixed(1)))
// Mock 档位节点:实际由接口返回,格式为 number[]
const tierNodes = [0, 100, 130, 160, 190, 220]
return {
nextTierHours: 100,
remainHours: 12.5,
currentTier: 1,
tierProgress: 58,
filledPct,
clampedSparkPct: Math.max(0, Math.min(100, filledPct)),
ticks: buildTicks(tierNodes, 220),
shineDurMs: calcShineDur(filledPct),
sparkDurMs: SPARK_DUR_MS,
shineRunning: false,
sparkRunning: false,
basicHours: '77.5',
bonusHours: '12',
totalHours: String(total),
tierCompleted: true,
bonusMoney: '800',
incomeMonth: '2月',
prevMonth: '1月',
incomeFormatted: '6,206',
incomeTrend: '↓368',
incomeTrendDir: 'down',
}
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
pinnedTasks: [] as EnrichedTask[],
normalTasks: [] as EnrichedTask[],
abandonedTasks: [] as EnrichedTask[],
taskCount: 0,
bannerMetrics: [] as Array<{ label: string; value: string }>,
bannerTitle: '',
/* CHANGE 2026-03-13 | banner 重做:添加用户信息字段,对齐 H5 原型 */
userName: '小燕',
userRole: '助教',
storeName: '广州朗朗桌球',
avatarUrl: '/assets/images/avatar-coach.png', // MOCK 头像地址
/* CHANGE 2026-03-13 | tagSvgMap 已移除,标签恢复 CSS 渐变实现 */
perfData: {
nextTierHours: 0,
remainHours: 0,
currentTier: 0,
tierProgress: 0,
filledPct: 0,
clampedSparkPct: 0,
ticks: [],
shineDurMs: 1000,
sparkDurMs: 1400,
shineRunning: false,
sparkRunning: false,
basicHours: '0',
bonusHours: '0',
totalHours: '0',
tierCompleted: false,
bonusMoney: '0',
incomeMonth: '',
prevMonth: '',
incomeFormatted: '0',
incomeTrend: '',
incomeTrendDir: 'up' as 'up' | 'down',
} as PerfData,
stampAnimated: false,
hasMore: true,
// --- 调试面板 ---
showDebugPanel: false,
debugTotalHours: 87.5,
debugBasicHours: 77.5,
debugBonusHours: 12,
debugPreset: -1,
contextMenuVisible: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuTarget: {} as EnrichedTask,
abandonModalVisible: false,
abandonTarget: {} as EnrichedTask,
noteModalVisible: false,
noteTarget: {} as EnrichedTask,
aiColor: 'indigo', // 随机 AI 配色,页面加载时随机选取
},
_longPressed: false,
_animTimer: null as ReturnType<typeof setTimeout> | null,
onLoad() {
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
const aiColor = colors[Math.floor(Math.random() * colors.length)]
this.setData({ aiColor })
this.loadData()
},
onReady() {
// 页面渲染完成后启动动画循环
setTimeout(() => {
this.setData({ stampAnimated: true })
this._startAnimLoop()
}, 100)
},
onShow() {
// 每次显示页面时重新随机 AI 配色,并恢复动画循环
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
const aiColor = colors[Math.floor(Math.random() * colors.length)]
this.setData({ aiColor })
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'task' })
// 若页面从后台恢复,重启动画循环
if (this.data.pageState === 'normal' && !this._animTimer) {
this._startAnimLoop()
}
},
onHide() {
// 页面不可见时停止动画,节省性能
this._stopAnimLoop()
},
onUnload() {
this._stopAnimLoop()
},
/* ──────────────────────────────────────────────────────
* JS 动画状态机
* 每轮流程:
* 1. 读当前 filledPct重新计算 shineDurMs
* 2. 启动高光shineRunning=true等 shineDurMs
* 3. 等 SPARK_DELAY_MS可为负即与高光末尾重叠
* 4. 启动火花sparkRunning=true等 SPARK_DUR_MS
* 5. 停止火花sparkRunning=false
* 6. 等 NEXT_LOOP_DELAY_MS可为负即提前启动下轮
* 7. 回到第1步
* ────────────────────────────────────────────────────── */
_startAnimLoop() {
this._stopAnimLoop() // 防止重复启动
this._runAnimStep()
},
_stopAnimLoop() {
if (this._animTimer !== null) {
clearTimeout(this._animTimer)
this._animTimer = null
}
// 停止时重置动画状态
this.setData({
'perfData.shineRunning': false,
'perfData.sparkRunning': false,
})
},
_runAnimStep() {
// 每轮开始时重新读当前进度,重新计算高光时长
const filledPct = this.data.perfData.filledPct ?? 0
const shineDurMs = calcShineDur(filledPct)
console.log(`[动画] filledPct=${filledPct}% shineDurMs=${shineDurMs}ms`)
// 阶段1启动高光
this.setData({
'perfData.shineRunning': true,
'perfData.sparkRunning': false,
'perfData.shineDurMs': shineDurMs,
})
// 阶段2高光结束后 + SPARK_DELAY_MS → 点亮光柱+火花
// 若 SPARK_DELAY_MS 为负,高光还未结束就提前点火
const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS)
this._animTimer = setTimeout(() => {
// 阶段3火花迸发
this.setData({ 'perfData.sparkRunning': true })
// 阶段4火花持续 SPARK_DUR_MS 后熄灭
this._animTimer = setTimeout(() => {
this.setData({
'perfData.shineRunning': false,
'perfData.sparkRunning': false,
})
// 阶段5等 NEXT_LOOP_DELAY_MS 后启动下一轮
const nextDelay = Math.max(0, NEXT_LOOP_DELAY_MS)
this._animTimer = setTimeout(() => {
this._runAnimStep()
}, nextDelay)
}, SPARK_DUR_MS)
}, sparkTriggerDelay)
},
onPullDownRefresh() {
this.loadData(() => {
wx.stopPullDownRefresh()
})
},
onReachBottom() {
if (!this.data.hasMore) return
this.setData({ hasMore: false })
wx.showToast({ title: '没有更多了', icon: 'none' })
},
loadData(cb?: () => void) {
this.setData({ pageState: 'loading', stampAnimated: false })
setTimeout(() => {
/* CHANGE 2026-03-13 | mock 数据贴近 H5 原型7 条任务2 置顶 + 3 一般 + 2 已放弃) */
const allTasks: Task[] = [
...mockTasks,
{
id: 'task-007',
customerName: '孙丽',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '客户回访',
deadline: '2026-03-06',
heartScore: 3.5,
hobbies: [],
isPinned: false,
hasNote: false,
status: 'abandoned',
},
]
const enriched = allTasks.map(enrichTask)
const pinnedTasks = enriched.filter((t) => t.isPinned && !t.isAbandoned)
const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && t.status === 'pending')
const abandonedTasks = enriched.filter((t) => t.isAbandoned)
const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length
const perfData = buildPerfData()
const perf = mockPerformance
const bannerTitle = `${perf.currentTier}`
const bannerMetrics: Array<{ label: string; value: string }> = []
this.setData({
pageState: totalCount > 0 ? 'normal' : 'empty',
pinnedTasks,
normalTasks,
abandonedTasks,
taskCount: totalCount,
bannerTitle,
bannerMetrics,
perfData,
hasMore: true,
})
if (perfData.tierCompleted) {
setTimeout(() => {
this.setData({ stampAnimated: true })
}, 300)
}
cb?.()
}, 600)
},
onRetry() {
this.loadData()
},
onTaskTap(e: WechatMiniprogram.TouchEvent) {
if (this._longPressed) {
this._longPressed = false
return
}
const { id } = e.currentTarget.dataset
wx.navigateTo({
url: `${DETAIL_ROUTE}?id=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onPerformanceTap() {
wx.navigateTo({
url: '/pages/performance/performance',
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onTaskLongPress(e: WechatMiniprogram.TouchEvent) {
this._longPressed = true
const { group, index } = e.currentTarget.dataset
const groupKey = group as 'pinned' | 'normal' | 'abandoned'
const groupMap: Record<string, EnrichedTask[]> = {
pinned: this.data.pinnedTasks,
normal: this.data.normalTasks,
abandoned: this.data.abandonedTasks,
}
const target = groupMap[groupKey]?.[index]
if (!target) return
const touch = e.touches[0]
const sysInfo = wx.getSystemInfoSync()
const menuW = 175
const menuH = 220
let x = touch.clientX
let y = touch.clientY
if (x + menuW > sysInfo.windowWidth - 16) x = sysInfo.windowWidth - menuW - 16
if (x < 16) x = 16
if (y + menuH > sysInfo.windowHeight - 16) y = sysInfo.windowHeight - menuH - 16
if (y < 16) y = 16
this.setData({
contextMenuVisible: true,
contextMenuX: x,
contextMenuY: y,
contextMenuTarget: target,
})
},
onCloseContextMenu() {
this.setData({ contextMenuVisible: false })
},
noop() {},
onCtxPin() {
const target = this.data.contextMenuTarget
const isPinned = !target.isPinned
wx.showToast({
title: isPinned ? `已置顶「${target.customerName}` : `已取消置顶「${target.customerName}`,
icon: 'none',
})
this.setData({ contextMenuVisible: false })
this._updateTaskPin(target.id, isPinned)
},
onCtxNote() {
const target = this.data.contextMenuTarget
this.setData({
contextMenuVisible: false,
noteModalVisible: true,
noteTarget: target,
})
},
onCtxAI() {
const target = this.data.contextMenuTarget
this.setData({ contextMenuVisible: false })
wx.navigateTo({
url: `/pages/ai-chat/ai-chat?taskId=${target.id}&customerName=${target.customerName}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onCtxAbandon() {
const target = this.data.contextMenuTarget
this.setData({
contextMenuVisible: false,
abandonModalVisible: true,
abandonTarget: target,
})
},
/** 放弃弹窗 - 确认 */
onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
const { reason } = e.detail
const target = this.data.abandonTarget
wx.showToast({ title: `已放弃「${target.customerName}`, icon: 'none' })
this.setData({ abandonModalVisible: false })
this._updateTaskAbandon(target.id, reason)
},
/** 放弃弹窗 - 取消 */
onAbandonCancel() {
this.setData({ abandonModalVisible: false })
},
/** 长按菜单 - 取消放弃(已放弃任务) */
onCtxCancelAbandon() {
const target = this.data.contextMenuTarget
this.setData({ contextMenuVisible: false })
wx.showLoading({ title: '处理中...' })
setTimeout(() => {
wx.hideLoading()
wx.showToast({ title: `已取消放弃「${target.customerName}`, icon: 'success' })
this._updateTaskCancelAbandon(target.id)
}, 500)
},
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
const { score, content } = e.detail
const target = this.data.noteTarget
wx.showToast({ title: `已保存「${target.customerName}」备注`, icon: 'success' })
this.setData({ noteModalVisible: false })
console.log('[note]', target.id, score, content)
},
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
_updateTaskPin(taskId: string, isPinned: boolean) {
const allTasks = [
...this.data.pinnedTasks,
...this.data.normalTasks,
...this.data.abandonedTasks,
].map((t) => (t.id === taskId ? { ...t, isPinned } : t))
this.setData({
pinnedTasks: allTasks.filter((t) => t.isPinned && !t.isAbandoned),
normalTasks: allTasks.filter((t) => !t.isPinned && !t.isAbandoned),
abandonedTasks: allTasks.filter((t) => t.isAbandoned),
})
},
_updateTaskAbandon(taskId: string, reason: string) {
const allTasks = [
...this.data.pinnedTasks,
...this.data.normalTasks,
...this.data.abandonedTasks,
].map((t) =>
t.id === taskId
? { ...t, isAbandoned: true, isPinned: false, status: 'abandoned' as const, abandonReason: reason }
: t,
)
const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned)
const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned)
const abandonedTasks = allTasks.filter((t) => t.isAbandoned)
this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length })
},
/** 切换调试面板 */
toggleDebugPanel() {
this.setData({ showDebugPanel: !this.data.showDebugPanel })
},
/** 调试 - 拖动总课时滑块 */
onDebugTotalHours(e: WechatMiniprogram.SliderChange) {
const total = e.detail.value
this.setData({ debugTotalHours: total, debugPreset: -1 })
this._applyDebugHours(this.data.debugBasicHours, this.data.debugBonusHours, total)
},
/** 调试 - 拖动基础课时滑块 */
onDebugBasicHours(e: WechatMiniprogram.SliderChange) {
const basic = e.detail.value
const total = basic + this.data.debugBonusHours
this.setData({ debugBasicHours: basic, debugTotalHours: total, debugPreset: -1 })
this._applyDebugHours(basic, this.data.debugBonusHours, total)
},
/** 调试 - 拖动激励课时滑块 */
onDebugBonusHours(e: WechatMiniprogram.SliderChange) {
const bonus = e.detail.value
const total = this.data.debugBasicHours + bonus
this.setData({ debugBonusHours: bonus, debugTotalHours: total, debugPreset: -1 })
this._applyDebugHours(this.data.debugBasicHours, bonus, total)
},
/** 调试 - 快速预设档位 */
onDebugPreset(e: WechatMiniprogram.BaseEvent) {
const preset = e.currentTarget.dataset.preset as number
const presets: Array<{ basic: number; bonus: number; total: number }> = [
{ basic: 45, bonus: 5, total: 50 }, // 未完成 (段0中间)
{ basic: 90, bonus: 10, total: 100 }, // 恰好达100h
{ basic: 115, bonus: 15, total: 130 }, // 恰好达130h
{ basic: 145, bonus: 15, total: 160 }, // 恰好达160h
{ basic: 195, bonus: 25, total: 220 }, // 满档220h
]
const p = presets[preset]
if (!p) return
this.setData({ debugBasicHours: p.basic, debugBonusHours: p.bonus, debugTotalHours: p.total, debugPreset: preset })
this._applyDebugHours(p.basic, p.bonus, p.total)
},
/** 调试 - 切换盖戳动画 */
onDebugToggleStamp() {
const completed = !this.data.perfData.tierCompleted
this.setData({
'perfData.tierCompleted': completed,
stampAnimated: false,
})
if (completed) {
setTimeout(() => this.setData({ stampAnimated: true }), 50)
}
},
/** 内部:根据课时数值重新计算档位进度并更新 perfData */
_applyDebugHours(basic: number, bonus: number, total: number) {
// 档位刻度:[0, 100, 130, 160, 190, 220]
const tiers = [0, 100, 130, 160, 190, 220]
let currentTier = 0
for (let i = 1; i < tiers.length; i++) {
if (total >= tiers[i]) currentTier = i
else break
}
// 当前段内进度百分比
const segStart = tiers[currentTier]
const segEnd = tiers[currentTier + 1] ?? tiers[tiers.length - 1]
const tierProgress = segEnd > segStart
? Math.min(100, Math.round(((total - segStart) / (segEnd - segStart)) * 100))
: 100
const nextTierHours = tiers[currentTier + 1] ?? 220
const remainHours = Math.max(0, nextTierHours - total)
const tierCompleted = total >= 220
const filledPct = Math.min(100, Math.round((total / 220) * 1000) / 10)
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
this.setData({
'perfData.totalHours': String(total),
'perfData.basicHours': String(basic),
'perfData.bonusHours': String(bonus),
'perfData.currentTier': currentTier,
'perfData.tierProgress': tierProgress,
'perfData.filledPct': filledPct,
'perfData.clampedSparkPct': Math.max(0, Math.min(100, filledPct)),
'perfData.ticks': buildTicks(tierNodes, 220),
'perfData.shineDurMs': calcShineDur(filledPct),
'perfData.sparkDurMs': SPARK_DUR_MS,
'perfData.nextTierHours': nextTierHours,
'perfData.remainHours': remainHours,
'perfData.tierCompleted': tierCompleted,
stampAnimated: false,
})
// 进度变化后重启动画循环,使下一轮立即用新进度重新计算高光时长
this._startAnimLoop()
if (tierCompleted) {
setTimeout(() => this.setData({ stampAnimated: true }), 50)
}
},
/** 取消放弃任务 - 将任务从已放弃列表移出至一般任务 */
_updateTaskCancelAbandon(taskId: string) {
const allTasks = [
...this.data.pinnedTasks,
...this.data.normalTasks,
...this.data.abandonedTasks,
].map((t) =>
t.id === taskId
? { ...t, isAbandoned: false, isPinned: false, status: 'pending' as const, abandonReason: undefined }
: t,
)
const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned)
const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned)
const abandonedTasks = allTasks.filter((t) => t.isAbandoned)
this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length })
},
})

View File

@@ -0,0 +1,412 @@
<!-- 任务列表页 — 2026-03-13 全量重写1:1 对齐 H5 原型 task-list.html -->
<!-- AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-13 | 重写 task-list 页面 1:1 还原 H5 原型 | 全量重写 WXML |
| 2026-03-13 | banner 错位重做 | 移除通用 banner 组件,页面内实现完整 banner |
| 2026-03-13 | 背景+纹理合并 SVG | 渐变+纹理+光晕合并为 SVG |
| 2026-03-13 | 5项修复+精确还原 | 恢复纹理CSS层、盖戳改回CSS实现、头像引用修复、abandoned标签恢复CSS灰化、全量line-height校准 |
-->
<view class="page-task-list">
<!-- ====== 顶部 Banner 区域 — 对齐 H5 .banner-bg.theme-blue.texture-aurora ====== -->
<!-- CHANGE 2026-03-13 | banner 背景SVG 做渐变底图 + CSS 做纹理叠加SVG pattern 在小程序中不渲染) -->
<view class="banner-area">
<image class="banner-bg-img" src="/assets/images/banner-bg-combined.svg" mode="aspectFill" />
<!-- 纹理层CSS repeating-linear-gradient 实现斜线网格SVG pattern 在小程序 image 中不生效 -->
<view class="banner-texture"></view>
<!-- 用户信息区 — H5: .px-5.pt-10.pb-3 -->
<view class="user-info-section">
<view class="user-info-row">
<!-- 头像 — H5: w-14 h-14 rounded-2xl bg-white/20 -->
<view class="avatar-wrap">
<image src="/assets/images/avatar-coach.png" mode="aspectFill" class="avatar-img" />
</view>
<!-- 姓名+标签+门店 -->
<view class="user-detail">
<view class="user-name-row">
<text class="user-name">{{userName}}</text>
<text class="user-role-tag">{{userRole}}</text>
</view>
<view class="user-store-row">
<text class="user-store">{{storeName}}</text>
</view>
</view>
</view>
</view>
<!-- 业绩进度卡片 — H5: .mx-4 > .bg-white/15.backdrop-blur-md.rounded-2xl -->
<view class="perf-card">
<!-- L1: 跳档提示 -->
<view class="perf-l1">
<view class="perf-l1-left">
<text class="perf-label">距离{{perfData.nextTierHours}}小时仅剩</text>
<text class="perf-accent">{{perfData.remainHours}}小时</text>
</view>
<view class="perf-l1-right" bindtap="onPerformanceTap">
<text class="perf-secondary">查看详情</text>
<t-icon name="chevron-right" size="22rpx" color="rgba(255,255,255,0.7)" />
</view>
</view>
<!-- L2: 5段档位进度条组件 -->
<view class="perf-l2">
<perf-progress-bar
filledPct="{{perfData.filledPct}}"
clampedSparkPct="{{perfData.clampedSparkPct}}"
currentTier="{{perfData.currentTier}}"
ticks="{{perfData.ticks}}"
shineRunning="{{perfData.shineRunning}}"
sparkRunning="{{perfData.sparkRunning}}"
shineDurMs="{{perfData.shineDurMs}}"
sparkDurMs="{{perfData.sparkDurMs}}"
/>
</view>
<!-- L3: 课时 + 红戳 + 奖金 -->
<view class="perf-l3">
<view class="perf-l3-left">
<view class="perf-hours-wrap">
<view class="perf-hours-row">
<text class="hours-green">{{perfData.basicHours}}</text>
<text class="hours-sep">|</text>
<text class="hours-yellow">{{perfData.bonusHours}}</text>
<text class="hours-sep">|</text>
<text class="hours-white">{{perfData.totalHours}}</text>
</view>
<view class="hours-label-row">
<text class="hours-label">基础课 | 激励课 | 全部</text>
</view>
<!-- 红戳徽章 — SVG 实现 -->
<image
class="stamp-badge {{stampAnimated ? 'stamp-animate' : ''}}"
wx:if="{{perfData.tierCompleted}}"
src="/assets/images/stamp-badge.svg"
/>
</view>
</view>
<view class="perf-l3-right">
<view class="bonus-wrap">
<text class="bonus-amount">{{perfData.bonusMoney}}</text>
<text class="bonus-unit">元</text>
</view>
<view class="bonus-label-row">
<text class="bonus-label">达{{perfData.nextTierHours}}h即得</text>
</view>
</view>
</view>
<!-- L4: 预计收入 -->
<view class="perf-l4">
<text class="perf-l4-label">{{perfData.incomeMonth}}预计收入 | 比{{perfData.prevMonth}}同期</text>
<view class="perf-l4-right" bindtap="onPerformanceTap">
<text class="income-value">¥{{perfData.incomeFormatted}}</text>
<text class="income-trend {{perfData.incomeTrendDir === 'down' ? 'trend-down' : ''}}">{{perfData.incomeTrend}}</text>
<t-icon name="chevron-right" size="28rpx" color="rgba(255,255,255,0.7)" />
</view>
</view>
</view>
</view>
<!-- ====== Loading 骨架屏 ====== -->
<view class="state-loading" wx:if="{{pageState === 'loading'}}">
<view class="loading-placeholder" wx:for="{{[1,2,3]}}" wx:key="*this">
<view class="ph-line ph-line--title"></view>
<view class="ph-line ph-line--body"></view>
<view class="ph-line ph-line--short"></view>
</view>
</view>
<!-- ====== 空状态 ====== -->
<view class="state-empty" wx:if="{{pageState === 'empty'}}">
<text class="empty-text">暂无待办任务</text>
</view>
<!-- ====== Error 状态 ====== -->
<view class="state-error" wx:if="{{pageState === 'error'}}">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" bindtap="onRetry">
<text class="retry-btn-text">重试</text>
</view>
</view>
<!-- ====== 任务列表区域 ====== -->
<view class="task-section" wx:if="{{pageState === 'normal'}}">
<!-- 标题行 -->
<view class="section-header">
<text class="section-title">今日 客户维护</text>
<text class="section-count">共 {{taskCount}} 项</text>
</view>
<!-- 📌 置顶区域 -->
<view class="task-group" wx:if="{{pinnedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--pinned">📌 置顶</text>
<text class="group-count">{{pinnedTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card {{item.isPinned ? 'task-card--pinned' : ''}} task-card--{{item.taskType}}"
wx:for="{{pinnedTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="pinned" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-2">
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 一般任务区域 -->
<view class="task-group" wx:if="{{normalTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--normal">正常任务</text>
<text class="group-count">{{normalTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card task-card--{{item.taskType}}"
wx:for="{{normalTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="normal" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-2">
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 已放弃区域 -->
<view class="task-group" wx:if="{{abandonedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--abandoned">已放弃</text>
<text class="group-count">{{abandonedTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card task-card--abandoned"
wx:for="{{abandonedTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="abandoned" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<!-- CHANGE 2026-03-13 | abandoned 标签保留原始类型标签,通过 CSS 灰化(对齐 H5 行为) -->
<view class="task-type-tag task-type-tag--{{item.taskType}} task-type-tag--abandoned">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name customer-name--abandoned">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<view class="card-row-2">
<text class="visit-text visit-text--abandoned">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
<text class="abandon-reason">放弃原因:{{item.abandonReason}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" wx:if="{{!hasMore}}">
<text class="load-more-text">没有更多了</text>
</view>
</view>
<!-- ====== P3: 长按上下文菜单 ====== -->
<view class="ctx-overlay {{contextMenuVisible ? 'ctx-overlay--active' : ''}}" bindtap="onCloseContextMenu"></view>
<view class="ctx-menu {{contextMenuVisible ? 'ctx-menu--active' : ''}}"
style="left:{{contextMenuX}}px;top:{{contextMenuY}}px" catchtap="noop">
<!-- 已放弃任务:显示"取消放弃" -->
<block wx:if="{{contextMenuTarget.isAbandoned}}">
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxCancelAbandon">
<text class="ctx-emoji">↩️</text>
<text class="ctx-text">取消放弃</text>
</view>
</block>
<!-- 一般/置顶任务:显示标准菜单 -->
<block wx:else>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxPin">
<text class="ctx-emoji">📌</text>
<text class="ctx-text">{{contextMenuTarget.isPinned ? '取消置顶' : '置顶'}}</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxNote">
<text class="ctx-emoji">📝</text>
<text class="ctx-text">备注</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAI">
<text class="ctx-emoji">🤖</text>
<text class="ctx-text">问问AI助手</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAbandon">
<text class="ctx-emoji">🗑️</text>
<text class="ctx-text">放弃任务</text>
</view>
</block>
</view>
<!-- ====== P4: 放弃弹窗 ====== -->
<abandon-modal
visible="{{abandonModalVisible}}"
customerName="{{abandonTarget.customerName || ''}}"
bind:confirm="onAbandonConfirm"
bind:cancel="onAbandonCancel"
/>
<!-- 备注弹窗 -->
<note-modal
visible="{{noteModalVisible}}"
customerName="{{noteTarget.customerName || ''}}"
showExpandBtn="{{true}}"
showRating="{{true}}"
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- 开发调试 FAB -->
<dev-fab />
<!-- ====== 调试面板 ====== -->
<view class="debug-panel {{showDebugPanel ? 'debug-panel--visible' : ''}}" catchtap="noop">
<view class="debug-header">
<text class="debug-title">🔧 调试工具</text>
<view class="debug-close" bindtap="toggleDebugPanel" hover-class="debug-close--hover">
<t-icon name="close" size="32rpx" color="#5e5e5e" />
</view>
</view>
<!-- 课时进度控制 -->
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">📊 当前课时总量:</text>
<text class="debug-value-chip">{{debugTotalHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="220" step="1"
value="{{debugTotalHours}}"
bindchange="onDebugTotalHours"
activeColor="#10b981"
backgroundColor="#e7e7e7"
block-size="24"
/>
<view class="debug-tick-row">
<text class="debug-tick">0</text>
<text class="debug-tick debug-tick--key">100</text>
<text class="debug-tick debug-tick--key debug-tick--current">130</text>
<text class="debug-tick debug-tick--key">160</text>
<text class="debug-tick debug-tick--key">190</text>
<text class="debug-tick">220</text>
</view>
</view>
<!-- 基础 / 激励课时分配 -->
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">🟢 基础课时:</text>
<text class="debug-value-chip debug-chip--green">{{debugBasicHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="220" step="0.5"
value="{{debugBasicHours}}"
bindchange="onDebugBasicHours"
activeColor="#6ee7b7"
backgroundColor="#e7e7e7"
block-size="22"
/>
</view>
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">🟡 激励课时:</text>
<text class="debug-value-chip debug-chip--yellow">{{debugBonusHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="80" step="0.5"
value="{{debugBonusHours}}"
bindchange="onDebugBonusHours"
activeColor="#fbbf24"
backgroundColor="#e7e7e7"
block-size="22"
/>
</view>
<!-- 档位进度预设 -->
<view class="debug-section">
<text class="debug-label">🎯 快速预设档位:</text>
<view class="debug-btn-group">
<view class="debug-btn {{debugPreset === 0 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="0">未完成</view>
<view class="debug-btn {{debugPreset === 1 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="1">达100h</view>
<view class="debug-btn {{debugPreset === 2 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="2">达130h</view>
<view class="debug-btn {{debugPreset === 3 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="3">达160h</view>
<view class="debug-btn {{debugPreset === 4 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="4">满档220h</view>
</view>
</view>
</view>
<!-- 调试触发按钮 -->
<view class="debug-trigger {{showDebugPanel ? 'debug-trigger--active' : ''}}" bindtap="toggleDebugPanel" hover-class="debug-trigger--hover">
<text class="debug-trigger-icon">🔧</text>
</view>
</view>

File diff suppressed because it is too large Load Diff