微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 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.getSystemInfoSync()
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, #e0f2fe 0%, #f0f9ff 50%, #ecfeff 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: rgba(255, 255, 255, 0.95);
border-bottom: 1rpx solid rgba(229, 231, 235, 0.5);
}
.navbar-back {
position: absolute;
left: 28rpx;
padding: 8rpx;
}
/* text-base=16px→28rpx */
.navbar-title {
font-size: 28rpx;
font-weight: 500;
color: #242424;
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, #0052d9, #60a5fa);
border-radius: 28rpx;
padding: 36rpx;
margin-bottom: 28rpx;
box-shadow: 0 14rpx 36rpx rgba(0, 82, 217, 0.2);
}
/* 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: rgba(255, 255, 255, 0.2);
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: #ffffff;
}
/* text-sm=14px→24rpx */
.welcome-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 300;
}
/* ---- 审核流程步骤条 p-4=16px→28rpx, rounded-xl=12px→22rpx ---- */
.steps-bar {
background: rgba(255, 255, 255, 0.1);
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: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.step-circle--active {
background: #ffffff;
color: #0052d9;
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: #ffffff;
border-radius: 28rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.04);
}
/* px-5=20px→36rpx, py-4=16px→28rpx+2rpx 视觉补偿 */
.form-item {
padding: 30rpx 36rpx;
}
.form-item--border {
border-bottom: 2rpx solid #f3f3f3;
}
/* 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: #242424;
}
/* text-sm=14px→24rpx */
.required {
color: #e34d59;
font-size: 24rpx;
}
/* text-xs=12px→22rpx */
.optional-tag {
font-size: 20rpx;
color: #a6a6a6;
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: #f8f8f8;
border-radius: 22rpx;
border: 2rpx solid #f3f3f3;
font-size: 24rpx;
font-weight: 300;
color: #242424;
box-sizing: border-box;
}
.form-input::placeholder {
color: #c5c5c5;
font-weight: 300;
}
/* ---- 表单提示(移入底部固定区) ---- */
.form-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: #a6a6a6;
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: rgba(255, 255, 255, 0.95);
border-top: 2rpx solid #f3f3f3;
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, #0052d9, #3b82f6);
border-radius: 22rpx;
box-shadow: 0 10rpx 28rpx rgba(0, 82, 217, 0.3);
}
.submit-btn--disabled {
opacity: 0.6;
}
/* text-base=16px→28rpx */
.submit-btn-text {
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
}
/* text-xs=12px→22rpx, mt-3=12px→22rpx */
.bottom-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: #c5c5c5;
margin-top: 22rpx;
font-weight: 300;
}

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "助教看板",
"enablePullDownRefresh": true,
"usingComponents": {
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/components/board-tab-bar/board-tab-bar",
"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,263 @@
// 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片
// TODO: 联调时替换 mock 数据为真实 API 调用
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> = {
'星级': '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
levelClass: string
skills: Array<{ text: string; cls: string }>
topCustomers: string[]
// 定档业绩维度
perfHours: string
perfHoursBefore?: string
perfGap?: string
perfReached: boolean
// 工资维度
salary: string
salaryPerfHours: string
salaryPerfBefore?: string
// 客源储值维度
svAmount: string
svCustomerCount: string
svConsume: string
// 任务维度
taskRecall: string
taskCallback: string
}
/** Mock 数据(忠于 H5 原型 6 位助教) */
const MOCK_COACHES: CoachItem[] = [
{
id: 'c1', name: '小燕', initial: '小',
avatarGradient: 'avatar--blue',
level: '星级', levelClass: LEVEL_CLASS['星级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💖 王先生', '💖 李女士', '💛 赵总'],
perfHours: '86.2h', perfHoursBefore: '92.0h', perfGap: '距升档 13.8h', perfReached: false,
salary: '¥12,680', salaryPerfHours: '86.2h', salaryPerfBefore: '92.0h',
svAmount: '¥45,200', svCustomerCount: '18', svConsume: '¥8,600',
taskRecall: '18', taskCallback: '14',
},
{
id: 'c2', name: '泡芙', initial: '泡',
avatarGradient: 'avatar--green',
level: '高级', levelClass: LEVEL_CLASS['高级'],
skills: [{ text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 陈先生', '💛 刘女士', '💛 黄总'],
perfHours: '72.5h', perfHoursBefore: '78.0h', perfGap: '距升档 7.5h', perfReached: false,
salary: '¥10,200', salaryPerfHours: '72.5h', salaryPerfBefore: '78.0h',
svAmount: '¥38,600', svCustomerCount: '15', svConsume: '¥6,200',
taskRecall: '15', taskCallback: '13',
},
{
id: 'c3', name: 'Amy', initial: 'A',
avatarGradient: 'avatar--pink',
level: '星级', levelClass: LEVEL_CLASS['星级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 张先生', '💛 周女士', '💛 吴总'],
perfHours: '68.0h', perfHoursBefore: '72.5h', perfGap: '距升档 32.0h', perfReached: false,
salary: '¥9,800', salaryPerfHours: '68.0h', salaryPerfBefore: '72.5h',
svAmount: '¥32,100', svCustomerCount: '14', svConsume: '¥5,800',
taskRecall: '12', taskCallback: '13',
},
{
id: 'c4', name: 'Mia', initial: 'M',
avatarGradient: 'avatar--amber',
level: '中级', levelClass: LEVEL_CLASS['中级'],
skills: [{ text: '🀄', cls: SKILL_CLASS['🀄'] }],
topCustomers: ['💛 赵先生', '💛 吴女士', '💛 孙总'],
perfHours: '55.0h', perfGap: '距升档 5.0h', perfReached: false,
salary: '¥7,500', salaryPerfHours: '55.0h',
svAmount: '¥28,500', svCustomerCount: '12', svConsume: '¥4,100',
taskRecall: '10', taskCallback: '10',
},
{
id: 'c5', name: '糖糖', initial: '糖',
avatarGradient: 'avatar--purple',
level: '初级', levelClass: LEVEL_CLASS['初级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💛 钱先生', '💛 孙女士', '💛 周总'],
perfHours: '42.0h', perfHoursBefore: '45.0h', perfReached: true,
salary: '¥6,200', salaryPerfHours: '42.0h', salaryPerfBefore: '45.0h',
svAmount: '¥22,000', svCustomerCount: '10', svConsume: '¥3,500',
taskRecall: '8', taskCallback: '10',
},
{
id: 'c6', name: '露露', initial: '露',
avatarGradient: 'avatar--cyan',
level: '中级', levelClass: LEVEL_CLASS['中级'],
skills: [{ text: '🎤', cls: SKILL_CLASS['🎤'] }],
topCustomers: ['💛 郑先生', '💛 冯女士', '💛 陈总'],
perfHours: '38.0h', perfGap: '距升档 22.0h', perfReached: false,
salary: '¥5,100', salaryPerfHours: '38.0h',
svAmount: '¥18,300', svCustomerCount: '9', svConsume: '¥2,800',
taskRecall: '6', taskCallback: '9',
},
]
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
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
}
this.setData({ allCoaches: data, coaches: data, 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 })
},
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,153 @@
<!-- 助教看板页 — 忠于 H5 原型结构 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="70rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无助教数据" />
</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"
>
<view class="card-row">
<!-- 头像 -->
<view class="card-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>
<text class="level-tag {{item.levelClass}}">{{item.level}}</text>
<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.perfHours}}</text></text>
<text class="right-sub" wx:if="{{item.perfHoursBefore}}">折前 <text class="right-sub-val">{{item.perfHoursBefore}}</text></text>
</view>
<!-- 工资维度 -->
<view class="card-right" wx:elif="{{dimType === 'salary'}}">
<text class="salary-tag">预估</text>
<text class="salary-amount">{{item.salary}}</text>
</view>
<!-- 客源储值维度 -->
<view class="card-right" wx:elif="{{dimType === 'sv'}}">
<text class="right-sub">储值</text>
<text class="salary-amount">{{item.svAmount}}</text>
</view>
<!-- 任务维度 -->
<view class="card-right" wx:elif="{{dimType === 'task'}}">
<text class="right-text">召回 <text class="right-highlight">{{item.taskRecall}}</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.salaryPerfHours}}</text></text>
<text class="bottom-sub" wx:if="{{item.salaryPerfBefore}}">折前 <text class="bottom-sub-val">{{item.salaryPerfBefore}}</text></text>
</view>
<!-- 客源储值:客户数 | 消耗 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'sv'}}">
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{item.svCustomerCount}}</text>人</text>
<text class="bottom-divider">|</text>
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{item.svConsume}}</text></text>
</view>
<!-- 任务:回访数 -->
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{item.taskCallback}}</text></text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区(为自定义导航栏留空间) -->
<view class="safe-bottom"></view>
</block>
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 — 在导航栏上方 -->
<ai-float-button bottom="{{220}}" />
<dev-fab />

View File

@@ -0,0 +1,330 @@
/* 助教看板页 — H5 px × 2 × 0.875 精确转换 */
/* ===== 三态 ===== */
.page-loading,
.page-empty {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
/* ===== 看板 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;
}
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
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 ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 70rpx;
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: 14rpx;
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: 1.8;
}
/* ===== 助教列表 p-4=16px→28rpx, space-y-3=12px→20rpx ===== */
.coach-list {
padding: 24rpx 28rpx;
}
/* ===== 助教卡片 p-4=16px→28rpx, rounded-2xl=16px→28rpx ===== */
.coach-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.coach-card:active {
opacity: 0.96;
transform: scale(0.98);
}
/* gap-3=12px→20rpx视觉校准紧凑 */
.card-row {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
/* ===== 头像 w-11 h-11=44px→78rpx, text-base=16px→28rpx ===== */
.card-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.avatar-text {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
/* 头像渐变色(忠于 H5 原型 6 种) */
.avatar--blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar--green { background: linear-gradient(135deg, #4ade80, #10b981); }
.avatar--pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar--amber { background: linear-gradient(135deg, #fbbf24, #f97316); }
.avatar--purple { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.avatar--cyan { background: linear-gradient(135deg, #22d3ee, #14b8a6); }
/* ===== 信息区 ===== */
.card-info {
flex: 1;
min-width: 0;
}
/* gap-1.5=6px→10rpx */
.card-name-row {
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: nowrap;
}
/* text-base=16px→28rpx */
.card-name {
font-size: 28rpx;
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: 6rpx;
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: 6rpx;
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 推到右边) ===== */
.card-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10rpx;
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;
}
/* "折前"更淡更细 */
.right-sub {
font-size: 20rpx;
color: #c5c5c5;
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 — 储值维度缩小避免挤压客户列表 */
.salary-amount {
font-size: 28rpx;
font-weight: 700;
color: #242424;
}
/* ===== 第二行 mt-1.5=6px→12rpx, text-xs=12px→22rpx ===== */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
/* gap-2=8px→12rpx */
.customer-list {
display: flex;
align-items: center;
gap: 12rpx;
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,14 @@
{
"navigationBarTitleText": "客户看板",
"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": "/components/board-tab-bar/board-tab-bar",
"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,300 @@
// 客户看板页 — 8 个维度查看前 100 名客户
// TODO: 联调时替换 mock 数据为真实 API 调用
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: 'Amy', topCoachHeart: 8.5, topCoachScore: '8.5',
coachDetails: [
{ name: 'Amy', 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: 'Amy', coachRatio: '62%',
visitFreq: '2.1次/月',
daysAgo: 12,
assistants: [
{ name: 'Amy', 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: 'Amy', 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',
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)
},
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,301 @@
<!-- 客户看板页 — 忠于 H5 原型8 维度卡片模板 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="70rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无客户数据" />
</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"
wx:for="{{customers}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCustomerTap"
>
<!-- ===== 卡片头部:头像 + 姓名 + 右侧指标 ===== -->
<view class="card-header">
<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="margin-left: 16rpx; 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--warning 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 + wv.pct * 0.006}}"></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">次均消费 <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 bottom="{{220}}" />
<dev-fab />

View File

@@ -0,0 +1,637 @@
/* 客户看板页 — 忠于 H5 原型87.5% 缩放 */
/* ===== 三态 ===== */
.page-loading,
.page-empty {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
/* ===== 看板 Tab对齐 board-coach 规范) ===== */
.board-tabs {
display: flex;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
position: sticky;
top: 0;
z-index: 20;
}
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
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: 42rpx;
height: 5rpx;
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 3rpx;
}
/* ===== 筛选区域(对齐 board-coach 规范) ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 70rpx;
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;
}
.filter-bar-inner {
display: flex;
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 14rpx;
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: 1.8;
}
/* ===== 列表头部 ===== */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 28rpx 12rpx;
}
.list-header-left {
display: flex;
align-items: center;
gap: 8rpx;
}
.list-header-title {
font-size: 28rpx;
font-weight: 600;
color: #242424;
}
.list-header-sub {
font-size: 24rpx;
color: #a6a6a6;
}
.list-header-count {
font-size: 24rpx;
color: #c5c5c5;
}
/* ===== 客户列表 ===== */
.customer-list {
padding: 0 28rpx 24rpx;
margin-top: 4rpx;
}
/* ===== 客户卡片 ===== */
.customer-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx 26rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.customer-card:active {
opacity: 0.96;
transform: scale(0.98);
}
/* ===== 卡片头部 ===== */
.card-header {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 6rpx;
}
.card-avatar {
width: 66rpx;
height: 66rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
color: #ffffff;
font-size: 26rpx;
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: 28rpx;
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;
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
}
.card-header-spacer {
flex: 1;
}
/* 头部右侧指标区 */
.header-metrics {
display: flex;
align-items: center;
gap: 12rpx;
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: 36rpx;
font-weight: 700;
color: #0052d9;
line-height: 1;
}
.freq-big-unit {
font-size: 22rpx;
font-weight: 400;
color: #a6a6a6;
margin-left: 2rpx;
}
.freq-big-label {
font-size: 20rpx;
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;
font-weight: 400;
color: #a6a6a6;
}
.metric-gray {
font-size: 22rpx;
color: #a6a6a6;
}
.metric-dark {
color: #393939;
font-weight: 600;
}
.metric-error {
color: #e34d59;
font-weight: 700;
}
/* 超期标签 */
.overdue-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
font-weight: 700;
border-radius: 6rpx;
}
.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 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
}
.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: 6rpx 0 4rpx 80rpx;
}
.mid-text {
font-size: 24rpx;
color: #c5c5c5;
}
.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: 20rpx;
}
.mid-right {
margin-left: auto;
}
.mid-error {
color: #e34d59;
font-weight: 700;
}
/* ===== 网格布局 ===== */
.card-grid {
display: grid;
gap: 12rpx;
padding: 6rpx 0 4rpx 80rpx;
}
.card-grid--3 {
grid-template-columns: repeat(3, 1fr);
text-align: center;
}
.card-grid--4 {
grid-template-columns: repeat(4, 1fr);
text-align: center;
}
.grid-cell {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.grid-label {
font-size: 18rpx;
color: #a6a6a6;
}
.grid-val {
font-size: 24rpx;
font-weight: 600;
color: #393939;
}
.grid-val--success {
color: #00a870;
}
.grid-val--warning {
color: #ed7b2f;
}
.grid-val--lg {
font-size: 28rpx;
font-weight: 700;
}
/* ===== 迷你柱状图(最频繁维度) ===== */
.mini-chart {
padding: 8rpx 0 4rpx 80rpx;
}
.mini-chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 4rpx;
}
.mini-chart-label {
font-size: 18rpx;
color: #c5c5c5;
}
.mini-chart-bars {
display: flex;
align-items: flex-end;
gap: 6rpx;
height: 48rpx;
}
.mini-bar-col {
flex: 1;
display: flex;
align-items: flex-end;
height: 100%;
}
.mini-bar {
width: 100%;
background: rgba(0, 82, 217, 0.3);
border-radius: 4rpx 4rpx 0 0;
min-height: 4rpx;
}
.mini-chart-nums {
display: flex;
gap: 6rpx;
margin-top: 4rpx;
}
.mini-chart-num {
flex: 1;
text-align: center;
font-size: 18rpx;
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-top: 10rpx;
margin-left: 80rpx;
padding-top: 10rpx;
border-top: 2rpx solid rgba(0, 0, 0, 0.04);
}
.assistant-label {
font-size: 22rpx;
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;
font-weight: 500;
}
.assistant-name.assistant--assignee {
color: #e34d59;
font-weight: 700;
}
.assistant-name.assistant--abandoned {
color: #a6a6a6;
}
.assistant-name.assistant--normal {
color: #242424;
}
.assistant-sep {
font-size: 20rpx;
color: #c5c5c5;
margin: 0 6rpx;
}
/* 跟/弃 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;
}
.assistant-badge--follow {
background: linear-gradient(135deg, #e34d59 0%, #f26a76 100%);
border: 1rpx solid rgba(227, 77, 89, 0.28);
box-shadow: 0 2rpx 6rpx rgba(227, 77, 89, 0.28);
}
.assistant-badge--drop {
background: linear-gradient(135deg, #d4d4d4 0%, #b6b6b6 100%);
border: 1rpx solid rgba(120, 120, 120, 0.18);
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.14);
}
/* ===== 最专一维度:助教服务明细表 ===== */
.loyal-table {
padding: 6rpx 0 4rpx 80rpx;
border-left: 4rpx solid #eeeeee;
margin-left: 80rpx;
padding-left: 14rpx;
margin-top: 4rpx;
}
.loyal-row {
display: flex;
align-items: center;
gap: 4rpx;
padding: 6rpx 0;
}
.loyal-row--header {
padding-bottom: 8rpx;
}
.loyal-row--header .loyal-col {
font-size: 20rpx;
color: #c5c5c5;
}
.loyal-col {
flex: 1;
text-align: right;
font-size: 24rpx;
}
.loyal-col--name {
width: 140rpx;
flex: none;
text-align: left;
display: flex;
align-items: center;
gap: 4rpx;
}
.loyal-coach-name {
font-size: 22rpx;
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-weight: 600;
color: #393939;
}
.loyal-val--primary {
font-weight: 700;
color: #0052d9;
}
.loyal-val--gray {
color: #8b8b8b;
}
/* 最专一头部右侧 */
.header-metrics--loyal {
gap: 6rpx;
}
.loyal-top-name {
font-size: 24rpx;
font-weight: 600;
color: #e34d59;
}
.loyal-top-score {
font-size: 24rpx;
font-weight: 700;
color: #0052d9;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "看板",
"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,345 @@
// 财务看板页 — 忠于 H5 原型结构,内联 mock 数据
// TODO: 联调时替换 mock 数据为真实 API 调用
/** 目录板块定义 */
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' | 'normal',
/** 时间筛选 */
selectedTime: 'month',
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',
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,
scrollIntoView: '',
/** 提示弹窗 */
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: [
{ name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
{ name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
{ name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
{ name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
{ name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
{ name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
{ name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
{ name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
{ 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: '-¥45,200', compare: '3.1%' },
{ name: '赠送卡抵扣', value: '-¥42,016', compare: '2.5%' },
{ name: '团购差价', value: '-¥26,120', compare: '5.2%' },
],
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%',
},
},
},
onLoad() {
// mock 数据已内联,直接显示
},
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 }>) {
this.setData({ selectedTime: e.detail.value })
},
/** 区域筛选变更 */
onAreaChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedArea: e.detail.value })
},
/** 环比开关切换 */
toggleCompare() {
this.setData({ compareEnabled: !this.data.compareEnabled })
},
/** 目录导航开关 */
toggleToc() {
this.setData({ tocVisible: !this.data.tocVisible })
},
closeToc() {
this.setData({ tocVisible: false })
},
/** 目录项点击 → 滚动到对应板块 */
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,
scrollIntoView: sectionId,
})
}
},
/** 帮助图标点击 → 弹出说明 */
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 })
},
})

View File

@@ -0,0 +1,734 @@
<!-- 财务看板页 — 忠于 H5 原型结构 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无财务数据" />
</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>
<!-- 筛选区域 -->
<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">
<filter-dropdown
label="本月"
options="{{timeOptions}}"
value="{{selectedTime}}"
bind:change="onTimeChange"
/>
</view>
<!-- 区域筛选 -->
<view class="filter-item">
<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>
<!-- 滚动内容区 -->
<scroll-view
class="board-content"
scroll-y
scroll-into-view="{{scrollIntoView}}"
scroll-with-animation
>
<!-- ===== 板块 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 洞察 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">🤖</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>
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text>充值高但消耗低,会员活跃度需提升</text>
</view>
</view>
</view>
<!-- ===== 板块 2: 预收资产 ===== -->
<view id="section-recharge" 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>
<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: 28rpx;">赠送卡统计详情</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" wx:for="{{recharge.giftRows}}" wx:key="label">
<view class="gift-col gift-col--name">
<text class="gift-row-label">{{item.label}}</text>
<text class="gift-row-total">{{item.total}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.totalCompare}}</text>
</view>
</view>
<view class="gift-col">
<text class="gift-col-val">{{item.wine}}</text>
<view class="compare-row" 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="compare-row" 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="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.couponCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 全类别会员卡余额合计 -->
<view class="total-balance-row">
<view class="total-balance-left">
<text class="total-balance-label">全类别会员卡余额合计</text>
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</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>
</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="name">
<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" wx:if="{{compareEnabled && item.bookedCompare}}">
<text class="compare-text-up-xs">↑{{item.bookedCompare}}</text>
</view>
</view>
</view>
</block>
</view>
<!-- 收入确认(损益链) -->
<view class="sub-title-row" style="margin-top: 28rpx;">
<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">
<text class="flow-detail-name">{{item.name}}</text>
<view class="flow-detail-right">
<text class="flow-detail-val flow-detail-val--red">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<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>
</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>
</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>
<!-- 充值收入 -->
<text class="flow-group-label" style="margin-top: 20rpx;">充值收入</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>
</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>
</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" 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" 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" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.avgHourlyCompare}}</text>
</view>
</view>
</view>
<!-- 明细行 -->
<view class="coach-fin-row" 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" 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" 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" 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">
<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" 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" 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" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.avgHourlyCompare}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</scroll-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 />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "对话历史",
"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"
}
}

View File

@@ -0,0 +1,76 @@
import { mockChatHistory } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 带展示标签的对话历史项 */
interface ChatHistoryDisplay {
id: string
title: string
lastMessage: string
timestamp: string
customerName?: string
/** 格式化后的时间标签 */
timeLabel: string
}
Page({
data: {
/** 页面状态loading / empty / normal */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 对话历史列表 */
list: [] as ChatHistoryDisplay[],
},
onLoad() {
this.loadData()
},
/** 加载数据 */
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const sorted = sortByTimestamp(mockChatHistory)
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
...item,
timeLabel: this.formatTime(item.timestamp),
}))
this.setData({
list,
pageState: list.length === 0 ? 'empty' : 'normal',
})
}, 400)
},
/** 格式化时间为相对标签 */
formatTime(timestamp: string): string {
const now = new Date()
const target = new Date(timestamp)
const diffMs = now.getTime() - target.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHour = Math.floor(diffMs / 3600000)
const diffDay = Math.floor(diffMs / 86400000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
if (diffHour < 24) return `${diffHour}小时前`
if (diffDay < 7) return `${diffDay}天前`
const month = target.getMonth() + 1
const day = target.getDate()
return `${month}${day}`
},
/** 点击对话记录 → 跳转 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,48 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无对话记录" />
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- 对话列表 -->
<view class="chat-list">
<view
class="chat-item"
wx:for="{{list}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onItemTap"
>
<view class="chat-icon-box">
<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 bottom="{{120}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,89 @@
/* ========== 加载态 & 空态 ========== */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
/* ========== 对话列表 ========== */
.chat-list {
background: #ffffff;
}
.chat-item {
display: flex;
align-items: center;
padding: 32rpx;
gap: 24rpx;
border-bottom: 1rpx solid var(--color-gray-1, #f3f3f3);
transition: background 0.2s ease;
}
.chat-item:active {
background: var(--color-gray-1, #f3f3f3);
}
/* 图标容器 */
.chat-icon-box {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, var(--color-primary, #0052d9), #4d8cf5);
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,7 @@
{
"navigationBarTitleText": "AI 助手",
"usingComponents": {
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,168 @@
// pages/chat/chat.ts — AI 对话页
import { mockChatMessages } from '../../utils/mock-data'
import type { ChatMessage } from '../../utils/mock-data'
import { simulateStreamOutput } from '../../utils/chat'
/** 将 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] }))
}
/** 为消息列表中的 referenceCard 补充 dataList 字段 */
function enrichMessages(msgs: ChatMessage[]) {
return msgs.map((m) => ({
...m,
referenceCard: m.referenceCard
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
: undefined,
}))
}
/** Mock AI 回复模板 */
const mockAIReplies = [
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
]
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 消息列表 */
messages: [] as Array<ChatMessage & { 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: '',
},
/** 消息计数器,用于生成唯一 ID */
_msgCounter: 0,
onLoad(options) {
const customerId = options?.customerId || ''
this.setData({ customerId })
this.loadMessages(customerId)
},
/** 加载消息Mock */
loadMessages(customerId: string) {
this.setData({ pageState: 'loading' })
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)
},
/** 输入框内容变化 */
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 userMsg = {
id: `msg-user-${this._msgCounter}`,
role: 'user' as const,
content: text,
timestamp: new Date().toISOString(),
}
const messages = [...this.data.messages, userMsg]
this.setData({
messages,
inputText: '',
pageState: 'normal',
})
this.scrollToBottom()
// 模拟 AI 回复
setTimeout(() => {
this.triggerAIReply()
}, 300)
},
/** 触发 AI 流式回复 */
triggerAIReply() {
this._msgCounter++
const aiMsgId = `msg-ai-${this._msgCounter}`
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
// 先添加空的 AI 消息占位
const aiMsg = {
id: aiMsgId,
role: 'assistant' as const,
content: '',
timestamp: new Date().toISOString(),
}
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() {
// 使用 nextTick 确保 DOM 更新后再滚动
setTimeout(() => {
this.setData({ scrollToId: '' })
setTimeout(() => {
this.setData({ scrollToId: 'scroll-bottom' })
}, 50)
}, 50)
},
})

View File

@@ -0,0 +1,125 @@
<!-- pages/chat/chat.wxml — AI 对话页 -->
<!-- 加载态 -->
<view class="loading-container" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="48rpx" text="加载中..." />
</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}}"
>
<!-- 引用卡片(从其他页面跳转时显示) -->
<view class="reference-card" wx:if="{{referenceCard}}">
<view class="reference-header">
<t-icon name="file-copy" size="32rpx" color="var(--color-gray-7)" />
<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-icon">🤖</view>
<text class="empty-text">你好,我是 AI 助手</text>
<text class="empty-sub">有什么可以帮你的?</text>
</view>
<!-- 消息气泡列表 -->
<block wx:for="{{messages}}" wx:key="id">
<!-- 用户消息:右对齐蓝色 -->
<view
class="message-row message-user"
wx:if="{{item.role === 'user'}}"
id="msg-{{item.id}}"
>
<view class="bubble bubble-user">
<text class="bubble-text">{{item.content}}</text>
</view>
</view>
<!-- AI 消息:左对齐白色 -->
<view
class="message-row message-assistant"
wx:else
id="msg-{{item.id}}"
>
<view class="ai-avatar">
<text class="ai-avatar-emoji">🤖</text>
</view>
<view class="bubble-wrapper">
<view class="bubble bubble-assistant">
<text class="bubble-text">{{item.content}}</text>
</view>
<!-- 引用卡片AI 消息内联) -->
<view class="inline-ref-card" 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>
</block>
<!-- AI 正在输入指示器 -->
<view class="message-row message-assistant" wx:if="{{isStreaming && !streamingContent}}" id="msg-typing">
<view class="ai-avatar">
<text class="ai-avatar-emoji">🤖</text>
</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 safe-area-bottom">
<view class="input-wrapper">
<input
class="chat-input"
value="{{inputText}}"
placeholder="输入消息..."
placeholder-class="input-placeholder"
confirm-type="send"
bindinput="onInputChange"
bindconfirm="onSendMessage"
disabled="{{isStreaming}}"
/>
</view>
<view
class="send-btn {{inputText.length > 0 && !isStreaming ? 'send-btn-active' : 'send-btn-disabled'}}"
bindtap="onSendMessage"
>
<t-icon name="send" size="40rpx" color="{{inputText.length > 0 && !isStreaming ? '#ffffff' : 'var(--color-gray-6)'}}" />
</view>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,289 @@
/* pages/chat/chat.wxss — AI 对话页样式 */
/* 加载态 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
/* 页面容器 */
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--color-gray-1);
}
/* ========== 消息列表 ========== */
.message-list {
flex: 1;
padding: 24rpx 32rpx;
padding-bottom: 0;
overflow-y: auto;
}
.scroll-bottom-spacer {
height: 32rpx;
}
/* ========== 引用卡片(页面顶部) ========== */
.reference-card {
background-color: var(--color-gray-2, #eeeeee);
border-radius: var(--radius-lg);
padding: 24rpx;
margin-bottom: 24rpx;
}
.reference-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.reference-source {
font-size: var(--font-xs);
color: var(--color-gray-7);
}
.reference-summary {
font-size: var(--font-sm);
color: var(--color-gray-9);
line-height: 1.5;
}
/* ========== 空对话提示 ========== */
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: var(--font-lg);
color: var(--color-gray-13);
font-weight: 500;
margin-bottom: 8rpx;
}
.empty-sub {
font-size: var(--font-sm);
color: var(--color-gray-7);
}
/* ========== 消息行 ========== */
.message-row {
display: flex;
margin-bottom: 24rpx;
}
.message-user {
justify-content: flex-end;
}
.message-assistant {
justify-content: flex-start;
gap: 16rpx;
}
/* ========== AI 头像 ========== */
.ai-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-primary), #4d8ff7);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-avatar-emoji {
font-size: 36rpx;
line-height: 1;
}
/* ========== 气泡 ========== */
.bubble-wrapper {
max-width: 80%;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.bubble {
padding: 20rpx 28rpx;
line-height: 1.6;
word-break: break-all;
}
.bubble-text {
font-size: var(--font-sm);
line-height: 1.6;
}
/* 用户气泡:蓝色,右上角方角 */
.bubble-user {
max-width: 80%;
background-color: var(--color-primary);
border-radius: 32rpx 8rpx 32rpx 32rpx;
}
.bubble-user .bubble-text {
color: #ffffff;
}
/* AI 气泡:白色,左上角方角 */
.bubble-assistant {
background-color: #ffffff;
border-radius: 8rpx 32rpx 32rpx 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.bubble-assistant .bubble-text {
color: var(--color-gray-13);
}
/* ========== AI 引用卡片(内联) ========== */
.inline-ref-card {
background-color: #ffffff;
border-radius: var(--radius-lg);
padding: 20rpx 24rpx;
border-left: 6rpx solid var(--color-primary);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.inline-ref-header {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.inline-ref-type {
font-size: var(--font-xs);
color: var(--color-primary);
font-weight: 500;
}
.inline-ref-title {
font-size: var(--font-sm);
color: var(--color-gray-13);
font-weight: 500;
}
.inline-ref-summary {
font-size: var(--font-xs);
color: var(--color-gray-8);
margin-bottom: 12rpx;
display: block;
}
.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: var(--font-xs);
color: var(--color-gray-7);
}
.ref-data-value {
font-size: var(--font-xs);
color: var(--color-gray-13);
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);
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 {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx;
background-color: #ffffff;
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
}
.input-wrapper {
flex: 1;
background-color: var(--color-gray-1);
border-radius: 48rpx;
padding: 16rpx 28rpx;
}
.chat-input {
width: 100%;
font-size: var(--font-sm);
color: var(--color-gray-13);
line-height: 1.4;
}
.input-placeholder {
color: var(--color-gray-6);
font-size: var(--font-sm);
}
/* 发送按钮 */
.send-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.send-btn-active {
background-color: var(--color-primary);
}
.send-btn-disabled {
background-color: var(--color-gray-1);
}

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "助教详情",
"usingComponents": {
"banner": "/components/banner/banner",
"note-modal": "/components/note-modal/note-modal",
"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",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,217 @@
import { mockCoaches } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 助教详情(含绩效、备注等扩展数据) */
interface CoachDetail {
id: string
name: string
avatar: string
level: string
skills: string[]
workYears: number
customerCount: number
/** 绩效指标 */
performance: {
monthlyHours: number
monthlySalary: number
customerBalance: number
tasksCompleted: 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
}
/** 内联 Mock 数据:助教详情扩展 */
const mockCoachDetail: CoachDetail = {
id: 'coach-001',
name: '小燕',
avatar: '/assets/images/avatar-default.png',
level: '星级',
skills: ['中🎱', '🎯 斯诺克'],
workYears: 3,
customerCount: 68,
performance: {
monthlyHours: 87.5,
monthlySalary: 6950,
customerBalance: 86200,
tasksCompleted: 38,
},
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-05 14:30', score: 9, customerName: '管理员' },
{ id: 'n2', content: '需要加强斯诺克教学技巧', timestamp: '2026-02-28 10:00', score: 7, customerName: '管理员' },
{ id: 'n3', content: '客户王先生反馈服务态度很好', timestamp: '2026-02-20 16:45', score: 8, customerName: '王先生' },
],
}
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 助教 ID */
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** Banner 指标 */
bannerMetrics: [] as Array<{ label: string; value: string }>,
/** 绩效指标卡片 */
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
/** 收入明细 Tab */
incomeTab: 'this' as 'this' | 'last',
/** 当前收入明细 */
currentIncome: [] as IncomeItem[],
/** 当前收入合计 */
incomeTotal: '',
/** 排序后的备注列表 */
sortedNotes: [] as NoteItem[],
/** 备注弹窗 */
noteModalVisible: false,
},
onLoad(options) {
const id = options?.id || ''
this.setData({ coachId: id })
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// 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 bannerMetrics = [
{ label: '工龄', value: `${detail.workYears}` },
{ label: '客户', value: `${detail.customerCount}` },
]
const perfCards = [
{ label: '本月定档业绩', value: `${detail.performance.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: `¥${detail.performance.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: `¥${detail.performance.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: `${detail.performance.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
this.setData({
pageState: 'normal',
detail,
bannerMetrics,
perfCards,
sortedNotes: sorted,
})
this.switchIncomeTab('this')
}, 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)
},
/** 打开备注弹窗 */
onAddNote() {
this.setData({ noteModalVisible: true })
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
const { score, 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,
customerName: '我',
}
const notes = [newNote, ...this.data.sortedNotes]
this.setData({
noteModalVisible: false,
sortedNotes: notes,
})
wx.showToast({ title: '备注已保存', icon: 'success' })
},
/** 备注弹窗取消 */
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** 返回 */
onBack() {
wx.navigateBack()
},
/** 问问助手 */
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,142 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到助教信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域 -->
<view class="banner-area">
<view class="banner-bg"></view>
<view class="banner-overlay">
<!-- 导航栏 -->
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">助教详情</text>
<view class="nav-placeholder"></view>
</view>
<!-- 助教基本信息 -->
<view class="coach-header">
<view class="avatar-box">
<image class="avatar-img" src="{{detail.avatar}}" mode="aspectFill" />
</view>
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{detail.name}}</text>
<t-tag variant="light" size="small" theme="warning">{{detail.level}}</t-tag>
</view>
<view class="skill-row">
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="*this">{{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>
</view>
<view class="right-stat">
<text class="right-stat-label">客户</text>
<text class="right-stat-value">{{detail.customerCount}}人</text>
</view>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content">
<!-- 绩效概览 -->
<view class="card">
<text class="section-title title-blue">绩效概览</text>
<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>
<!-- 收入明细 -->
<view class="card">
<view class="card-header">
<text class="section-title title-green">收入明细</text>
<view class="income-tabs">
<text class="income-tab {{incomeTab === 'this' ? 'active' : ''}}"
data-tab="this" bindtap="onIncomeTabTap">本月</text>
<text class="income-tab {{incomeTab === 'last' ? 'active' : ''}}"
data-tab="last" bindtap="onIncomeTabTap">上月</text>
</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>
<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="info-circle" 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">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>添加备注</text>
</view>
</view>
<!-- 备注弹窗 -->
<note-modal
visible="{{noteModalVisible}}"
customerName="{{detail.name}}"
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{120}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,431 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* ========== Banner ========== */
.banner-area {
position: relative;
overflow: hidden;
}
.banner-bg {
width: 100%;
height: 380rpx;
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
display: block;
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back {
padding: 8rpx;
}
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
color: #ffffff;
}
.nav-placeholder {
width: 64rpx;
}
/* 助教头部信息 */
.coach-header {
display: flex;
align-items: center;
gap: 24rpx;
padding: 16rpx 40rpx 32rpx;
}
.avatar-box {
width: 112rpx;
height: 112rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
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: 12rpx;
margin-bottom: 12rpx;
}
.coach-name {
font-size: var(--font-lg, 36rpx);
font-weight: 600;
color: #ffffff;
}
.skill-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.skill-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 8rpx;
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.9);
}
.info-right-stats {
flex-shrink: 0;
text-align: right;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.right-stat {
display: flex;
align-items: baseline;
gap: 8rpx;
justify-content: flex-end;
}
.right-stat-label {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.7);
}
.right-stat-value {
font-size: var(--font-base, 32rpx);
font-weight: 700;
color: #ffffff;
}
/* ========== 主体内容 ========== */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* ========== 通用卡片 ========== */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0, 0, 0, 0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-base, 32rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
position: relative;
padding-left: 20rpx;
margin-bottom: 32rpx;
}
.card-header .section-title {
margin-bottom: 0;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 28rpx;
border-radius: 4rpx;
}
.title-blue::before {
background: linear-gradient(180deg, #0052d9, #5b9cf8);
}
.title-green::before {
background: linear-gradient(180deg, #00a870, #4cd964);
}
.title-orange::before {
background: linear-gradient(180deg, #ed7b2f, #ffc107);
}
.header-hint {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* ========== 绩效概览 ========== */
.perf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.perf-card {
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
border: 1rpx solid transparent;
}
.perf-blue {
background: linear-gradient(135deg, #eff6ff, #eef2ff);
border-color: rgba(191, 219, 254, 0.5);
}
.perf-green {
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
border-color: rgba(167, 243, 208, 0.5);
}
.perf-orange {
background: linear-gradient(135deg, #fff7ed, #fef3c7);
border-color: rgba(253, 186, 116, 0.5);
}
.perf-purple {
background: linear-gradient(135deg, #faf5ff, #ede9fe);
border-color: rgba(196, 181, 253, 0.5);
}
.perf-label {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
display: block;
margin-bottom: 8rpx;
}
.perf-value-row {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.perf-value {
font-size: 48rpx;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.perf-unit {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.perf-sub {
font-size: 22rpx;
color: var(--color-gray-5, #c5c5c5);
display: block;
margin-top: 4rpx;
}
/* ========== 收入明细 ========== */
.income-tabs {
display: flex;
align-items: center;
gap: 8rpx;
}
.income-tab {
font-size: var(--font-xs, 24rpx);
padding: 8rpx 20rpx;
border-radius: 100rpx;
color: var(--color-gray-7, #8b8b8b);
background: var(--color-gray-1, #f3f3f3);
}
.income-tab.active {
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
font-weight: 500;
}
.income-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.income-item {
display: flex;
align-items: center;
gap: 16rpx;
}
.income-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.dot-primary {
background: var(--color-primary, #0052d9);
}
.dot-success {
background: var(--color-success, #00a870);
}
.dot-warning {
background: var(--color-warning, #ed7b2f);
}
.dot-purple {
background: #7c3aed;
}
.income-label {
flex: 1;
font-size: var(--font-base, 32rpx);
color: var(--color-gray-9, #5e5e5e);
}
.income-amount {
font-size: var(--font-base, 32rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
font-variant-numeric: tabular-nums;
}
.income-total {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 20rpx;
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
}
.income-total-label {
font-size: var(--font-base, 32rpx);
font-weight: 600;
color: var(--color-gray-9, #5e5e5e);
}
.income-total-value {
font-size: var(--font-base, 32rpx);
font-weight: 700;
color: var(--color-success, #00a870);
font-variant-numeric: tabular-nums;
}
/* ========== 备注列表 ========== */
.note-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.note-item {
background: #fafafa;
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
padding: 24rpx;
}
.note-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.note-author {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-gray-13, #242424);
}
.note-time {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-content {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.note-score {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 12rpx;
}
.note-star {
font-size: 28rpx;
}
.note-score-value {
font-size: var(--font-xs, 24rpx);
color: var(--color-warning, #ed7b2f);
font-weight: 500;
}
.note-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* ========== 底部操作栏 ========== */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-chat {
flex: 1;
height: 88rpx;
background: linear-gradient(135deg, #0052d9, #3b82f6);
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 82, 217, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}
/* ========== 颜色工具类 ========== */
.text-primary {
color: var(--color-primary, #0052d9) !important;
}
.text-success {
color: var(--color-success, #00a870) !important;
}
.text-warning {
color: var(--color-warning, #ed7b2f) !important;
}
.text-purple {
color: #7c3aed !important;
}

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "客户详情",
"usingComponents": {
"heart-icon": "/components/heart-icon/heart-icon",
"star-rating": "/components/star-rating/star-rating",
"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",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,161 @@
import { mockCustomers, mockCustomerDetail } from '../../utils/mock-data'
import type { CustomerDetail, ConsumptionRecord } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 客户 ID */
customerId: '',
/** 客户详情 */
detail: null as CustomerDetail | null,
/** 排序后的消费记录 */
sortedRecords: [] as ConsumptionRecord[],
/** AI 洞察 */
aiInsight: {
summary: '高价值 VIP 客户,月均到店 4-5 次,偏好夜场中式台球,近期对斯诺克产生兴趣。社交属性强,常带固定球搭子,有拉新能力。储值余额充足,对促销活动响应积极。',
strategies: [
{ color: 'green', text: '最后到店距今 12 天,超出理想间隔 7 天,建议尽快安排助教主动联系召回' },
{ color: 'amber', text: '客户提到想练斯诺克走位,可推荐斯诺克专项课程包,结合储值优惠提升客单价' },
{ color: 'pink', text: '社交属性强,可邀请参加门店球友赛事活动,带动球搭子到店消费' },
],
},
/** 维客线索 */
clues: [
{ category: '客户基础', categoryColor: 'primary', text: '🎂 生日 3月15日 · VIP会员 · 注册2年', source: '系统' },
{ category: '消费习惯', categoryColor: 'success', text: '🌙 常来夜场 · 月均4-5次', source: '系统' },
{ category: '消费习惯', categoryColor: 'success', text: '💰 高客单价', source: '系统', detail: '近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%' },
{ category: '玩法偏好', categoryColor: 'purple', text: '🎱 偏爱中式 · 斯诺克进阶中', source: '系统' },
{ category: '促销接受', categoryColor: 'warning', text: '🍷 爱点酒水套餐 · 对储值活动敏感', source: '系统', detail: '最近3次到店均点了酒水套餐上次 ¥5000 储值活动当天即充值,对满赠类活动响应率高' },
{ category: '社交关系', categoryColor: 'pink', text: '👥 常带朋友 · 固定球搭子2人', source: '系统', detail: '近60天 80% 的到店为多人局常与「李哥」「阿杰」同行曾介绍2位新客办卡' },
{ category: '重要反馈', categoryColor: 'error', text: '⚠️ 上次提到想练斯诺克走位对球桌维护质量比较在意建议优先安排VIP房', source: '小燕' },
],
/** Banner 统计 */
bannerStats: {
balance: '¥8,600',
spend60d: '¥2,800',
idealInterval: '7天',
daysSinceVisit: '12天',
},
/** 助教任务 */
coachTasks: [
{
name: '小燕',
level: '高级助教',
levelColor: 'pink',
taskType: '高优先召回',
taskColor: 'red',
lastService: '02-20 21:30 · 2.5h',
bgClass: 'coach-card-red',
metrics: [
{ label: '近60天次数', value: '18次', color: 'primary' },
{ label: '总时长', value: '17h', color: '' },
{ label: '次均时长', value: '0.9h', color: 'warning' },
],
},
{
name: '泡芙',
level: '中级助教',
levelColor: 'purple',
taskType: '关系构建',
taskColor: 'pink',
lastService: '02-15 14:00 · 1.5h',
bgClass: 'coach-card-pink',
metrics: [
{ label: '近60天次数', value: '12次', color: 'primary' },
{ label: '总时长', value: '11h', color: '' },
{ label: '次均时长', value: '0.9h', color: 'warning' },
],
},
],
/** 最喜欢的助教 */
favoriteCoaches: [
{
name: '小燕',
emoji: '❤️',
relationIndex: '0.92',
indexColor: 'success',
bgClass: 'fav-card-pink',
stats: [
{ label: '基础', value: '12h', color: 'primary' },
{ label: '激励', value: '5h', color: 'warning' },
{ label: '上课', value: '18次', color: '' },
{ label: '充值', value: '¥5,000', color: 'success' },
],
},
{
name: '泡芙',
emoji: '💛',
relationIndex: '0.78',
indexColor: 'warning',
bgClass: 'fav-card-amber',
stats: [
{ label: '基础', value: '8h', color: 'primary' },
{ label: '激励', value: '3h', color: 'warning' },
{ label: '上课', value: '12次', color: '' },
{ label: '充值', value: '¥3,000', color: 'success' },
],
},
],
},
onLoad(options) {
const id = options?.id || ''
this.setData({ customerId: id })
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
// 先从 mockCustomers 查找基本信息,再用 mockCustomerDetail 补充
const customer = mockCustomers.find((c) => c.id === id)
const detail = customer
? { ...mockCustomerDetail, id: customer.id, name: customer.name, heartScore: customer.heartScore, tags: customer.tags }
: mockCustomerDetail
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const sorted = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
this.setData({
pageState: 'normal',
detail,
sortedRecords: sorted,
})
}, 500)
},
/** 发起对话 */
onStartChat() {
const id = this.data.customerId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 添加备注 */
onAddNote() {
wx.showToast({ title: '备注功能开发中', icon: 'none' })
},
/** 查看服务记录 */
onViewServiceRecords() {
const id = this.data.customerId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/customer-service-records/customer-service-records?customerId=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 返回 */
onBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,215 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到客户信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域 -->
<view class="banner-area">
<view class="banner-bg"></view>
<view class="banner-overlay">
<!-- 导航栏 -->
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">客户详情</text>
<view class="nav-placeholder"></view>
</view>
<!-- 客户基本信息 -->
<view class="customer-header">
<view class="avatar-box">
<text class="avatar-text">{{detail.name[0] || '?'}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{detail.name}}</text>
<t-tag wx:for="{{detail.tags}}" wx:key="*this"
variant="light" size="small" theme="warning"
class="customer-tag">{{item}}</t-tag>
</view>
<view class="sub-info">
<text class="phone">{{detail.phone}}</text>
<text class="member-id">VIP20231215</text>
</view>
</view>
</view>
<!-- Banner 统计指标 -->
<view class="banner-stats">
<view class="stat-item stat-border">
<text class="stat-value stat-green">{{bannerStats.balance}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{bannerStats.spend60d}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{bannerStats.idealInterval}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{bannerStats.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">
<text class="ai-icon-emoji">🤖</text>
<text class="ai-insight-label">AI 智能洞察</text>
</view>
<text class="ai-insight-summary">{{aiInsight.summary}}</text>
<view class="ai-strategy-box">
<text class="strategy-title">📋 当前推荐策略</text>
<view class="strategy-list">
<view class="strategy-item" wx:for="{{aiInsight.strategies}}" wx:key="index">
<view class="strategy-dot dot-{{item.color}}"></view>
<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>
<view class="ai-badge-box">
<text class="ai-badge-emoji">🤖</text>
<text class="ai-badge-text">AI智能洞察</text>
</view>
</view>
<view class="clue-list">
<view class="clue-item {{item.detail ? 'clue-with-detail' : ''}}" wx:for="{{clues}}" wx:key="index">
<view class="clue-main">
<view class="clue-category clue-cat-{{item.categoryColor}}">
<text>{{item.category}}</text>
</view>
<view class="clue-content">
<text class="clue-text">{{item.text}}</text>
<text class="clue-source">By:{{item.source}}</text>
</view>
</view>
<text class="clue-detail" wx:if="{{item.detail}}">{{item.detail}}</text>
</view>
</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>
<text class="coach-level level-{{item.levelColor}}">{{item.level}}</text>
</view>
<text class="coach-task-type type-{{item.taskColor}}">{{item.taskType}}</text>
</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">
<text class="section-title title-orange">消费记录</text>
<t-icon name="chevron-right" size="40rpx" color="#a6a6a6" />
</view>
<view class="record-list" wx:if="{{sortedRecords.length > 0}}">
<view class="record-item" wx:for="{{sortedRecords}}" wx:key="id">
<view class="record-header">
<view class="record-project">
<view class="record-dot"></view>
<text class="record-project-name">{{item.project}}</text>
</view>
<text class="record-date">{{item.date}}</text>
</view>
<view class="record-body">
<view class="record-info">
<text class="record-coach" wx:if="{{item.coachName !== '-'}}">助教:{{item.coachName}}</text>
<text class="record-duration" wx:if="{{item.duration > 0}}">{{item.duration}}分钟</text>
</view>
<text class="record-amount">¥{{item.amount}}</text>
</view>
</view>
</view>
<view class="record-empty" wx:else>
<t-icon name="info-circle" 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">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,680 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* ========== Banner ========== */
.banner-area {
position: relative;
overflow: hidden;
}
.banner-bg {
width: 100%;
height: 480rpx;
background: linear-gradient(135deg, #1a1a2e, #c9a84c);
display: block;
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back {
padding: 8rpx;
}
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
color: #ffffff;
}
.nav-placeholder {
width: 64rpx;
}
/* 客户头部信息 */
.customer-header {
display: flex;
align-items: center;
gap: 32rpx;
padding: 16rpx 40rpx 24rpx;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
}
.info-right {
flex: 1;
}
.name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.customer-name {
font-size: var(--font-xl, 40rpx);
font-weight: 600;
color: #ffffff;
}
.customer-tag {
flex-shrink: 0;
}
.sub-info {
display: flex;
align-items: center;
gap: 32rpx;
}
.phone,
.member-id {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.7);
}
/* Banner 统计 */
.banner-stats {
margin: 0 40rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg, 24rpx);
backdrop-filter: blur(8px);
display: flex;
}
.stat-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
}
.stat-border {
border-right: 1rpx solid rgba(255, 255, 255, 0.1);
}
.stat-value {
display: block;
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: #ffffff;
}
.stat-green {
color: #6ee7b7;
}
.stat-amber {
color: #fcd34d;
}
.stat-label {
display: block;
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.6);
margin-top: 8rpx;
}
/* ========== 主体内容 ========== */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* ========== AI 智能洞察卡片 ========== */
.ai-insight-card {
border-radius: var(--radius-xl, 32rpx);
overflow: hidden;
background: linear-gradient(135deg, #667eea, #764ba2);
padding: 40rpx;
color: #ffffff;
}
.ai-insight-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.ai-icon-small {
width: 48rpx;
height: 48rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-md, 16rpx);
padding: 8rpx;
}
.ai-icon-emoji {
font-size: 40rpx;
line-height: 1;
}
.ai-insight-label {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.ai-insight-summary {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.9);
line-height: 1.7;
margin-bottom: 24rpx;
}
.ai-strategy-box {
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
backdrop-filter: blur(8px);
}
.strategy-title {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.6);
margin-bottom: 16rpx;
display: block;
}
.strategy-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.strategy-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.strategy-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
}
.dot-green {
background: #6ee7b7;
}
.dot-amber {
background: #fcd34d;
}
.dot-pink {
background: #f9a8d4;
}
.strategy-text {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
}
/* ========== 通用卡片 ========== */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0, 0, 0, 0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
position: relative;
padding-left: 20rpx;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 28rpx;
border-radius: 4rpx;
}
.title-green::before {
background: linear-gradient(180deg, #00a870, #4cd964);
}
.title-blue::before {
background: linear-gradient(180deg, #0052d9, #5b9cf8);
}
.title-orange::before {
background: linear-gradient(180deg, #ed7b2f, #ffc107);
}
.title-pink::before {
background: linear-gradient(180deg, #e851a4, #f5a0c0);
}
.header-hint {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* AI 徽章 */
.ai-badge-box {
display: flex;
align-items: center;
gap: 8rpx;
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 100rpx;
}
.ai-badge-emoji {
font-size: 24rpx;
line-height: 1;
}
.ai-badge-text {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
}
/* ========== 维客线索 ========== */
.clue-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.clue-item {
background: #fafafa;
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
padding: 24rpx;
}
.clue-main {
display: flex;
align-items: flex-start;
gap: 24rpx;
}
.clue-category {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
line-height: 1.3;
text-align: center;
}
.clue-cat-primary {
background: rgba(0, 82, 217, 0.1);
color: var(--color-primary, #0052d9);
}
.clue-cat-success {
background: rgba(0, 168, 112, 0.1);
color: var(--color-success, #00a870);
}
.clue-cat-purple {
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
.clue-cat-warning {
background: rgba(237, 123, 47, 0.1);
color: var(--color-warning, #ed7b2f);
}
.clue-cat-pink {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
.clue-cat-error {
background: rgba(227, 77, 89, 0.1);
color: var(--color-error, #e34d59);
}
.clue-content {
flex: 1;
position: relative;
min-height: 80rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.clue-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-13, #242424);
line-height: 1.5;
}
.clue-source {
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
align-self: flex-end;
margin-top: 4rpx;
}
.clue-detail {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-7, #8b8b8b);
line-height: 1.6;
margin-top: 16rpx;
}
/* ========== 助教任务 ========== */
.coach-task-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.coach-task-card {
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
}
.coach-card-red {
background: linear-gradient(135deg, rgba(254, 226, 226, 0.8), rgba(255, 228, 230, 0.6));
border: 1rpx solid rgba(252, 165, 165, 0.6);
}
.coach-card-pink {
background: linear-gradient(135deg, rgba(252, 231, 243, 0.8), rgba(250, 232, 255, 0.6));
border: 1rpx solid rgba(249, 168, 212, 0.6);
}
.coach-task-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.coach-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.coach-name {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.coach-level {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 100rpx;
font-weight: 500;
}
.level-pink {
background: #fce7f3;
color: #be185d;
}
.level-purple {
background: #f3e8ff;
color: #7c3aed;
}
.coach-task-type {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 100rpx;
font-weight: 500;
}
.type-red {
background: #fee2e2;
color: #b91c1c;
}
.type-pink {
background: #fce7f3;
color: #be185d;
}
.coach-last-service {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-7, #8b8b8b);
margin-bottom: 16rpx;
}
.coach-metrics {
display: flex;
gap: 16rpx;
}
.coach-metric {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: var(--radius-md, 16rpx);
padding: 12rpx 0;
text-align: center;
}
.metric-label {
display: block;
font-size: 20rpx;
color: var(--color-gray-6, #a6a6a6);
margin-bottom: 4rpx;
}
.metric-value {
display: block;
font-size: var(--font-sm, 28rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
}
/* ========== 最喜欢的助教 ========== */
.fav-coach-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.fav-coach-card {
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
}
.fav-card-pink {
background: linear-gradient(135deg, rgba(252, 231, 243, 0.8), rgba(255, 228, 230, 0.6));
border: 1rpx solid rgba(249, 168, 212, 0.6);
}
.fav-card-amber {
background: linear-gradient(135deg, rgba(254, 243, 199, 0.8), rgba(254, 249, 195, 0.6));
border: 1rpx solid rgba(252, 211, 77, 0.6);
}
.fav-coach-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.fav-coach-name {
display: flex;
align-items: center;
gap: 20rpx;
}
.fav-emoji {
font-size: 36rpx;
}
.fav-name {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.fav-index {
display: flex;
align-items: center;
gap: 12rpx;
}
.fav-index-label {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-7, #8b8b8b);
}
.fav-index-value {
font-size: 36rpx;
font-weight: 700;
}
.fav-period {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-7, #8b8b8b);
margin-bottom: 12rpx;
padding-left: 4rpx;
}
.fav-stats {
display: flex;
gap: 16rpx;
}
.fav-stat {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: var(--radius-md, 16rpx);
padding: 16rpx 0;
text-align: center;
}
.fav-stat-label {
display: block;
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-7, #8b8b8b);
margin-bottom: 4rpx;
}
.fav-stat-value {
display: block;
font-size: var(--font-base, 32rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
}
/* ========== 消费记录 ========== */
.record-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.record-item {
background: #fafafa;
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
overflow: hidden;
}
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background: linear-gradient(90deg, #eff6ff, #eef2ff);
border-bottom: 1rpx solid rgba(219, 234, 254, 0.5);
}
.record-project {
display: flex;
align-items: center;
gap: 12rpx;
}
.record-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: var(--color-primary, #0052d9);
}
.record-project-name {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-primary, #0052d9);
}
.record-date {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-8, #777777);
}
.record-body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
}
.record-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.record-coach {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-8, #777777);
}
.record-duration {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.record-amount {
font-size: var(--font-base, 32rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
font-variant-numeric: tabular-nums;
}
.record-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* ========== 底部操作栏 ========== */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-chat {
flex: 1;
height: 88rpx;
background: linear-gradient(135deg, #0052d9, #3b82f6);
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 82, 217, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}
/* ========== 颜色工具类 ========== */
.text-primary {
color: var(--color-primary, #0052d9) !important;
}
.text-success {
color: var(--color-success, #00a870) !important;
}
.text-warning {
color: var(--color-warning, #ed7b2f) !important;
}
.text-error {
color: var(--color-error, #e34d59) !important;
}

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "服务记录",
"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"
}
}

View File

@@ -0,0 +1,223 @@
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data'
import type { ConsumptionRecord } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 服务记录(含月份分组信息) */
interface ServiceRecord extends ConsumptionRecord {
/** 格式化后的日期,如 "2月5日" */
dateLabel: string
/** 时间段,如 "15:00 - 17:00" */
timeRange: string
/** 时长文本,如 "2.0h" */
durationText: string
/** 课程类型标签 */
typeLabel: string
/** 类型样式 class */
typeClass: string
/** 台号/房间 */
tableNo: string
}
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 客户 ID */
customerId: '',
/** 客户名 */
customerName: '',
/** 客户名首字 */
customerInitial: '',
/** 客户电话 */
customerPhone: '139****5678',
/** 累计服务次数 */
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))
// 转换为展示格式
const records: ServiceRecord[] = monthRecords.map((r) => {
const d = new Date(r.date)
const month = d.getMonth() + 1
const day = d.getDate()
return {
...r,
dateLabel: `${month}${day}`,
timeRange: this.generateTimeRange(r.duration),
durationText: (r.duration / 60).toFixed(1) + 'h',
typeLabel: this.getTypeLabel(r.project),
typeClass: this.getTypeClass(r.project),
tableNo: this.getTableNo(r.id),
}
})
// 月度统计
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 '基础课'
},
/** 课程类型样式 */
getTypeClass(project: string): string {
if (project.includes('充值')) return 'type-tip'
if (project.includes('小组')) return 'type-vip'
if (project.includes('斯诺克')) return 'type-vip'
return 'type-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)
},
/** 触底加载 */
onReachBottom() {
// Mock 阶段数据有限,不做分页
if (this.data.loadingMore || !this.data.hasMore) return
this.setData({ loadingMore: true })
setTimeout(() => {
this.setData({ loadingMore: false, hasMore: false })
}, 500)
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,111 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无服务记录" />
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域 -->
<view class="banner-area">
<view class="banner-bg"></view>
<view class="banner-overlay">
<!-- 导航栏 -->
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">服务记录</text>
<view class="nav-placeholder"></view>
</view>
<!-- 客户信息 -->
<view class="customer-header">
<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>
<text class="phone-text">{{customerPhone}}</text>
</view>
<view class="sub-stats">
<text class="sub-stat">累计服务 <text class="stat-highlight">{{totalServiceCount}}</text> 次</text>
<text class="sub-stat">关系指数 <text class="stat-highlight">{{relationIndex}}</text></text>
</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>
<!-- 记录列表 -->
<view class="records-container">
<view class="record-card" wx:for="{{records}}" wx:key="id">
<view class="record-top">
<view class="record-date-time">
<text class="record-date">{{item.dateLabel}}</text>
<text class="record-time">{{item.timeRange}}</text>
</view>
<text class="record-duration">{{item.durationText}}</text>
</view>
<view class="record-bottom">
<text class="record-table">{{item.tableNo}}</text>
<text class="record-type {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="record-income">¥{{item.amount}}</text>
</view>
</view>
<!-- 无当月记录 -->
<view class="no-month-data" wx:if="{{records.length === 0}}">
<text class="no-month-text">本月暂无服务记录</text>
</view>
<!-- 底部提示 -->
<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 bottom="{{120}}" customerId="{{customerId}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,283 @@
/* ========== 加载态 & 空态 ========== */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
/* ========== Banner ========== */
.banner-area {
position: relative;
overflow: hidden;
}
.banner-bg {
width: 100%;
height: 320rpx;
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
display: block;
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back {
padding: 8rpx;
}
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
color: #ffffff;
}
.nav-placeholder {
width: 64rpx;
}
/* 客户头部 */
.customer-header {
display: flex;
align-items: center;
gap: 24rpx;
padding: 8rpx 40rpx 32rpx;
}
.avatar-box {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
flex-shrink: 0;
}
.avatar-text {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
.info-right {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
.customer-name {
font-size: var(--font-lg, 36rpx);
font-weight: 600;
color: #ffffff;
}
.phone-text {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.5);
}
.sub-stats {
display: flex;
align-items: center;
gap: 32rpx;
}
.sub-stat {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.7);
}
.stat-highlight {
color: #ffffff;
font-weight: 700;
font-size: var(--font-sm, 28rpx);
}
/* ========== 月份切换 ========== */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
background: #ffffff;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid var(--color-gray-2, #eeeeee);
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn.disabled {
opacity: 0.3;
}
.month-label {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
/* ========== 月度统计 ========== */
.month-summary {
display: flex;
align-items: flex-start;
background: #ffffff;
padding: 24rpx 0;
border-bottom: 1rpx solid var(--color-gray-2, #eeeeee);
}
.summary-item {
flex: 1;
text-align: center;
}
.summary-label {
display: block;
font-size: 20rpx;
color: var(--color-gray-6, #a6a6a6);
margin-bottom: 4rpx;
}
.summary-value {
display: block;
font-size: var(--font-lg, 36rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
font-variant-numeric: tabular-nums;
}
.value-primary {
color: var(--color-primary, #0052d9);
}
.value-warning {
color: var(--color-warning, #ed7b2f);
}
.summary-divider {
width: 1rpx;
height: 64rpx;
background: var(--color-gray-2, #eeeeee);
margin-top: 4rpx;
}
/* ========== 记录列表 ========== */
.records-container {
padding: 24rpx 32rpx;
padding-bottom: 160rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.record-card {
background: #ffffff;
border-radius: 28rpx;
padding: 28rpx 32rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #f0f0f0;
}
.record-card:active {
background: #fafafa;
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.record-date-time {
display: flex;
align-items: center;
gap: 16rpx;
}
.record-date {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.record-time {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.record-duration {
font-size: 30rpx;
font-weight: 700;
color: var(--color-success, #00a870);
font-variant-numeric: tabular-nums;
}
.record-bottom {
display: flex;
align-items: center;
gap: 16rpx;
}
.record-table {
font-size: var(--font-xs, 24rpx);
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 12rpx;
font-weight: 500;
}
.record-type {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 12rpx;
font-weight: 500;
}
.type-basic {
background: rgba(0, 168, 112, 0.1);
color: var(--color-success, #00a870);
}
.type-vip {
background: rgba(0, 82, 217, 0.1);
color: var(--color-primary, #0052d9);
}
.type-tip {
background: rgba(237, 123, 47, 0.1);
color: var(--color-warning, #ed7b2f);
}
.record-income {
font-size: var(--font-sm, 28rpx);
font-weight: 700;
color: var(--color-gray-13, #242424);
margin-left: auto;
font-variant-numeric: tabular-nums;
}
/* 无当月数据 */
.no-month-data {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.no-month-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* 底部提示 */
.list-footer {
text-align: center;
padding: 24rpx 0;
}
.footer-text {
font-size: 20rpx;
color: var(--color-gray-5, #c5c5c5);
}
/* 加载更多 */
.loading-more {
display: flex;
justify-content: center;
padding: 24rpx 0;
}

View File

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

View File

@@ -0,0 +1,182 @@
/**
* 开发调试面板页面
*
* 功能:
* - 展示当前用户上下文(角色、权限、绑定、门店)
* - 一键切换角色(后端真实修改 user_site_roles + 重签 token
* - 一键切换用户状态(后端真实修改 users.status + 重签 token
* - 页面跳转列表(点击跳转到任意已注册页面)
*/
import { request } from "../../utils/request"
// 页面列表分三段:正在迁移、已完成、未完成
const MIGRATING_PAGES = [
{ path: "pages/board-finance/board-finance", name: "财务看板" },
]
const DONE_PAGES = [
{ 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: "客户看板" },
]
const TODO_PAGES = [
{ path: "pages/task-list/task-list", name: "任务列表" },
{ path: "pages/my-profile/my-profile", name: "个人中心" },
{ path: "pages/task-detail/task-detail", name: "任务详情" },
{ path: "pages/task-detail-callback/task-detail-callback", name: "任务-回访" },
{ path: "pages/task-detail-priority/task-detail-priority", name: "任务-优先级" },
{ path: "pages/task-detail-relationship/task-detail-relationship", name: "任务-关系" },
{ path: "pages/notes/notes", name: "备忘录" },
{ path: "pages/performance/performance", name: "业绩总览" },
{ path: "pages/performance-records/performance-records", name: "业绩明细" },
{ path: "pages/customer-detail/customer-detail", name: "客户详情" },
{ path: "pages/customer-service-records/customer-service-records", name: "客户服务记录" },
{ path: "pages/coach-detail/coach-detail", name: "助教详情" },
{ path: "pages/chat/chat", name: "AI 对话" },
{ path: "pages/chat-history/chat-history", name: "对话历史" },
{ path: "pages/index/index", name: "首页" },
{ path: "pages/mvp/mvp", name: "MVP" },
{ path: "pages/logs/logs", name: "日志" },
]
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,66 @@
// pages/index/index.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})

View File

@@ -0,0 +1,5 @@
{
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button"
}
}

View File

@@ -0,0 +1,54 @@
// index.ts
// 获取应用实例
const app = getApp<IAppOption>()
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
Component({
data: {
motto: 'Hello World',
userInfo: {
avatarUrl: defaultAvatarUrl,
nickName: '',
},
hasUserInfo: false,
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
},
methods: {
// 事件处理函数
bindViewTap() {
wx.navigateTo({
url: '../logs/logs',
})
},
onChooseAvatar(e: any) {
const { avatarUrl } = e.detail
const { nickName } = this.data.userInfo
this.setData({
"userInfo.avatarUrl": avatarUrl,
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
})
},
onInputChange(e: any) {
const nickName = e.detail.value
const { avatarUrl } = this.data.userInfo
this.setData({
"userInfo.nickName": nickName,
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
})
},
getUserProfile() {
// 推荐使用wx.getUserProfile获取用户信息开发者每次通过该接口获取用户个人信息均需用户确认开发者妥善保管用户快速填写的头像昵称避免重复弹窗
wx.getUserProfile({
desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
success: (res) => {
console.log(res)
this.setData({
userInfo: res.userInfo,
hasUserInfo: true
})
}
})
},
},
})

View File

@@ -0,0 +1,30 @@
<!--index.wxml-->
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container">
<view class="userinfo">
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
<image class="avatar" src="{{userInfo.avatarUrl}}"></image>
</button>
<view class="nickname-wrapper">
<text class="nickname-label">昵称</text>
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
</view>
</block>
<block wx:elif="{{!hasUserInfo}}">
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
<view wx:else> 请使用2.10.4及以上版本基础库 </view>
</block>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
<t-button theme="primary">按钮</t-button>
</view>
</scroll-view>
<dev-fab />

View File

@@ -0,0 +1,62 @@
/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.userinfo {
display: flex;
flex-direction: column;
align-items: center;
color: #aaa;
width: 80%;
}
.userinfo-avatar {
overflow: hidden;
width: 128rpx;
height: 128rpx;
margin: 20rpx;
border-radius: 50%;
}
.usermotto {
margin-top: 200px;
}
.avatar-wrapper {
padding: 0;
width: 56px !important;
border-radius: 8px;
margin-top: 40px;
margin-bottom: 40px;
}
.avatar {
display: block;
width: 56px;
height: 56px;
}
.nickname-wrapper {
display: flex;
width: 100%;
padding: 16px;
box-sizing: border-box;
border-top: .5px solid rgba(0, 0, 0, 0.1);
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
color: black;
}
.nickname-label {
width: 105px;
}
.nickname-input {
flex: 1;
}

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,112 @@
import { request } from "../../utils/request"
import { API_BASE } from "../../utils/config"
/** develop 环境使用 dev-login 跳过微信 code2Session */
const isDevMode = API_BASE.startsWith("http://127.0.0.1")
Page({
data: {
agreed: false,
loading: false,
/** 系统状态栏高度px用于自定义导航栏顶部偏移 */
statusBarHeight: 20,
},
onLoad() {
const sysInfo = wx.getSystemInfoSync()
this.setData({ statusBarHeight: sysInfo.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 })
try {
let data: any
if (isDevMode) {
// 开发模式:直接调用 dev-login无需 wx.login
// 不传 status让后端保留用户当前状态首次创建默认 new
data = await request({
url: "/api/xcx/dev-login",
method: "POST",
data: { openid: "dev_test_openid" },
needAuth: false,
})
} else {
// 正式/体验:走微信 code2Session 流程
const loginRes = await new Promise<WechatMiniprogram.LoginSuccessCallbackResult>(
(resolve, reject) => {
wx.login({
success: resolve,
fail: reject,
})
},
)
data = await request({
url: "/api/xcx/login",
method: "POST",
data: { code: loginRes.code },
needAuth: false,
})
}
const app = getApp<IAppOption>()
app.globalData.token = data.access_token
app.globalData.refreshToken = data.refresh_token
wx.setStorageSync("token", data.access_token)
wx.setStorageSync("refreshToken", data.refresh_token)
// 持久化用户身份信息
app.globalData.authUser = {
userId: data.user_id,
status: data.user_status,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.user_status)
// 根据 user_status 路由
switch (data.user_status) {
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
case "rejected":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
case "disabled":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
default:
wx.reLaunch({ url: "/pages/apply/apply" })
break
}
} catch (err: any) {
const msg =
err?.statusCode === 403
? "账号已被禁用"
: err?.statusCode === 401
? "登录凭证无效,请重试"
: "登录失败,请稍后重试"
wx.showToast({ title: msg, icon: "none" })
} finally {
this.setData({ loading: false })
}
},
})

View File

@@ -0,0 +1,74 @@
<!--pages/login/login.wxml — 忠于 H5 原型,使用 TDesign 组件 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 装饰元素:模拟 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 feature-icon--primary">
<t-icon name="task" size="35rpx" color="#0052d9" />
</view>
<text class="feature-text">任务管理</text>
</view>
<view class="feature-item">
<view class="feature-icon feature-icon--success">
<t-icon name="chart-bar" size="35rpx" color="#00a870" />
</view>
<text class="feature-text">数据看板</text>
</view>
<view class="feature-item">
<view class="feature-icon feature-icon--warning">
<t-icon name="chat" size="35rpx" color="#ed7b2f" />
</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,281 @@
/* pages/login/login.wxss — 忠于 H5 原型TDesign 组件定制 */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 50%, #ecfeff 100%);
position: relative;
overflow: hidden;
box-sizing: border-box;
/* padding-top 由 JS statusBarHeight 动态设置box-sizing 确保 padding 包含在 100vh 内 */
}
/* ---- 装饰圆形 ---- */
.deco-circle {
position: absolute;
border-radius: 50%;
z-index: 0;
}
.deco-circle--1 {
width: 176rpx;
height: 176rpx;
background: radial-gradient(circle, rgba(0, 82, 217, 0.12) 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, #0052d9, #60a5fa);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 14rpx 42rpx rgba(0, 82, 217, 0.3);
}
.logo-icon {
width: 98rpx;
height: 98rpx;
}
.logo-dot {
position: absolute;
border-radius: 50%;
}
.logo-dot--tr {
width: 28rpx;
height: 28rpx;
background: #22d3ee;
top: -8rpx;
right: -8rpx;
box-shadow: 0 4rpx 10rpx rgba(34, 211, 238, 0.4);
}
.logo-dot--bl {
width: 22rpx;
height: 22rpx;
background: #93c5fd;
bottom: -14rpx;
left: -14rpx;
box-shadow: 0 4rpx 10rpx rgba(147, 197, 253, 0.4);
}
/* ---- 应用名称 ---- */
.app-name {
font-size: 42rpx;
font-weight: 700;
color: #242424;
margin-bottom: 14rpx;
}
.app-desc {
font-size: 24rpx;
color: #8b8b8b;
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.85);
border-radius: 28rpx;
padding: 36rpx 18rpx;
margin-bottom: 42rpx;
}
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
}
.feature-icon {
width: 70rpx;
height: 70rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.feature-icon--primary { background: rgba(0, 82, 217, 0.1); }
.feature-icon--success { background: rgba(0, 168, 112, 0.1); }
.feature-icon--warning { background: rgba(237, 123, 47, 0.1); }
.feature-text {
font-size: 22rpx;
color: #5e5e5e;
font-weight: 500;
}
/* ---- 底部操作区 ---- */
.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: #ffffff;
font-size: 28rpx;
font-weight: 500;
}
/* 未勾选协议 → 灰色禁用态(忠于原型 btn-disabled */
.login-btn--disabled {
background: #dcdcdc;
}
.login-btn--disabled .login-btn-text {
color: #ffffff;
}
/* 勾选协议 → 蓝色渐变(忠于原型 from-primary to-blue-500 */
.login-btn--active {
background: linear-gradient(135deg, #0052d9, #3b82f6);
box-shadow: 0 10rpx 28rpx rgba(0, 82, 217, 0.3);
}
/* ---- 协议勾选 ---- */
.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 #dcdcdc;
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: #0052d9;
border-color: #0052d9;
}
.agreement-text-wrap {
flex: 1;
font-size: 22rpx;
line-height: 1.625;
}
.agreement-text {
font-size: 22rpx;
color: #8b8b8b;
line-height: 1.625;
}
.link {
color: #0052d9;
font-weight: 500;
font-size: 22rpx;
line-height: 1.625;
}
/* ---- 底部提示 ---- */
.footer-tip {
display: block;
width: 100%;
text-align: center;
font-size: 22rpx;
color: #c5c5c5;
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,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "MVP验证"
}

View File

@@ -0,0 +1,45 @@
// MVP 全链路验证页面
// 从后端 API 读取 test."xcx-test" 表 ti 列第一行并显示
import { API_BASE } from "../../utils/config"
Page({
data: {
tiValue: "加载中...",
error: "",
loading: true,
},
onLoad() {
this.fetchData()
},
fetchData() {
this.setData({ loading: true, error: "" })
wx.request({
url: `${API_BASE}/api/xcx-test`,
method: "GET",
success: (res) => {
if (res.statusCode === 200 && res.data) {
const data = res.data as { ti: string }
this.setData({
tiValue: data.ti,
loading: false,
})
} else {
this.setData({
error: `请求失败: ${res.statusCode}`,
loading: false,
})
}
},
fail: (err) => {
this.setData({
error: `网络错误: ${err.errMsg}`,
loading: false,
})
},
})
},
})

View File

@@ -0,0 +1,19 @@
<!--MVP 全链路验证页面-->
<view class="container">
<view class="title">小程序 MVP 验证</view>
<view class="desc">数据来源: test_zqyy_app → test."xcx-test" → ti</view>
<view class="result" wx:if="{{!error}}">
<text class="value">{{tiValue}}</text>
</view>
<view class="error" wx:if="{{error}}">
<text>{{error}}</text>
</view>
<view class="retry" bindtap="fetchData">
<text>点击刷新</text>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,48 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 40rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.desc {
font-size: 24rpx;
color: #999;
margin-bottom: 60rpx;
}
.result {
background: #f0f9ff;
border: 2rpx solid #0ea5e9;
border-radius: 16rpx;
padding: 40rpx 80rpx;
margin-bottom: 40rpx;
}
.value {
font-size: 48rpx;
font-weight: bold;
color: #0369a1;
}
.error {
color: #dc2626;
font-size: 28rpx;
margin-bottom: 40rpx;
}
.retry {
padding: 20rpx 40rpx;
background: #0ea5e9;
color: #fff;
border-radius: 8rpx;
font-size: 28rpx;
}

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "我的",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"ai-float-button": "/components/ai-float-button/ai-float-button"
}
}

View File

@@ -0,0 +1,31 @@
import { mockUserProfile } from '../../utils/mock-data'
import { getMenuRoute, navigateTo } from '../../utils/router'
// TODO: 联调时替换为真实 API 获取用户信息
Page({
data: {
userInfo: mockUserProfile,
},
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,52 @@
<!-- 我的页面 -->
<view class="page-my-profile">
<!-- 用户信息区域 -->
<view class="user-card">
<image class="avatar" src="{{userInfo.avatar}}" mode="aspectFill" />
<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" bind:tap="onMenuTap" data-key="notes">
<view class="menu-left">
<view class="menu-icon icon-notes">
<t-icon name="edit-1" size="40rpx" color="#0052d9" />
</view>
<text class="menu-text">备注记录</text>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
<view class="menu-item" bind:tap="onMenuTap" data-key="chat-history">
<view class="menu-left">
<view class="menu-icon icon-chat">
<t-icon name="chat" size="40rpx" color="#00a870" />
</view>
<text class="menu-text">助手对话记录</text>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
<view class="menu-item menu-item--last" bind:tap="onLogout">
<view class="menu-left">
<view class="menu-icon icon-logout">
<t-icon name="poweroff" size="40rpx" color="#e34d59" />
</view>
<text class="menu-text">退出账号</text>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
</view>
</view>
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx -->
<ai-float-button visible="{{true}}" bottom="200" />
<dev-fab />

View File

@@ -0,0 +1,102 @@
.page-my-profile {
min-height: 100vh;
background: var(--color-bg-page, #f3f3f3);
}
/* 用户信息卡片 */
.user-card {
display: flex;
align-items: center;
gap: 32rpx;
padding: 48rpx;
background: #fff;
}
.avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.user-info {
flex: 1;
overflow: hidden;
}
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
.name {
font-size: 36rpx;
font-weight: 600;
color: var(--color-text-primary, #242424);
}
.role-tag {
padding: 4rpx 16rpx;
background: rgba(0, 82, 217, 0.1);
color: #0052d9;
font-size: 24rpx;
border-radius: 8rpx;
}
.store-name {
font-size: 28rpx;
color: var(--color-text-placeholder, #8b8b8b);
}
/* 菜单列表 */
.menu-list {
margin-top: 24rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
background: #fff;
border-bottom: 1rpx solid #f3f3f3;
}
.menu-item--last {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.icon-notes {
background: rgba(0, 82, 217, 0.1);
}
.icon-chat {
background: rgba(0, 168, 112, 0.1);
}
.icon-logout {
background: rgba(227, 77, 89, 0.1);
}
.menu-text {
font-size: 28rpx;
color: var(--color-text-primary, #242424);
}

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.getSystemInfoSync()
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,8 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": false,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,45 @@
import { mockNotes } from '../../utils/mock-data'
import type { Note } from '../../utils/mock-data'
Page({
data: {
loading: true,
error: false,
notes: [] as Note[],
/** 系统状态栏高度px用于自定义导航栏顶部偏移 */
statusBarHeight: 20,
},
onLoad() {
// 获取真实状态栏高度兼容所有机型iPhone 刘海屏、安卓异形屏等)
const sysInfo = wx.getSystemInfoSync()
this.setData({ statusBarHeight: sysInfo.statusBarHeight || 20 })
this.loadData()
},
loadData() {
this.setData({ loading: true, error: false })
setTimeout(() => {
// TODO: 替换为真实 API 调用 GET /api/xcx/notes
try {
this.setData({
loading: false,
notes: mockNotes,
})
} catch {
this.setData({ loading: false, error: true })
}
}, 400)
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
/** 错误态重试 */
onRetry() {
this.loadData()
},
})

View File

@@ -0,0 +1,45 @@
<!-- 自定义顶部导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-bar-inner">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="40rpx" color="#4b4b4b" />
</view>
<text class="nav-title">备注</text>
</view>
</view>
<!-- 加载态 -->
<view class="page-loading" wx:if="{{loading}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{error}}">
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" bindtap="onRetry">
<text>重试</text>
</view>
</view>
<!-- 空数据态 -->
<view class="page-empty" wx:elif="{{notes.length === 0}}">
<t-icon name="edit-1" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无备注记录</text>
</view>
<!-- 正常态 — 备注列表 -->
<view class="note-list" wx:else>
<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>
<text class="note-time">{{item.createdAt}}</text>
</view>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,130 @@
/* ========== 自定义导航栏 ========== */
.nav-bar {
/* padding-top 由 JS 动态设置wx.getSystemInfoSync().statusBarHeight */
background-color: #ffffff;
position: sticky;
top: 0;
z-index: 10;
}
.nav-bar-inner {
height: 88rpx;
display: flex;
align-items: center;
position: relative;
border-bottom: 2rpx solid #eeeeee;
padding: 0 32rpx;
}
.nav-back {
position: absolute;
left: 32rpx;
padding: 8rpx;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 500;
color: #242424;
}
/* ========== 页面背景 ========== */
page {
background-color: #f3f3f3;
min-height: 100vh;
}
/* ========== 加载态 / 空态 / 错误态 ========== */
.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: 28rpx;
color: #a6a6a6;
}
.retry-btn {
margin-top: 16rpx;
padding: 12rpx 48rpx;
background-color: #0052d9;
border-radius: 16rpx;
}
.retry-btn text {
font-size: 28rpx;
color: #ffffff;
}
/* ========== 备注列表 ========== */
.note-list {
padding: 32rpx;
}
/* 卡片间距H5 原型 space-y-3 = 24rpx */
.note-card + .note-card {
margin-top: 24rpx;
}
/* ========== 备注卡片 ========== */
/* H5: bg-white rounded-2xl p-4 shadow-sm */
.note-card {
background-color: #ffffff;
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
/* 备注正文 */
/* H5: text-sm text-gray-13 leading-relaxed mb-3 */
.note-content {
display: block;
font-size: 28rpx;
color: #242424;
line-height: 1.625;
margin-bottom: 24rpx;
}
/* 底部行:标签 + 时间 */
/* H5: flex items-center justify-between */
.note-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
/* ========== 标签样式 ========== */
/* H5: .note-tag { padding: 4px 10px; font-size: 12px; border-radius: 8px; border: 1px solid; } */
.note-tag {
padding: 8rpx 20rpx;
font-size: 24rpx;
border-radius: 16rpx;
border: 2rpx solid;
}
/* 客户标签 */
/* H5: .tag-customer { background: linear-gradient(135deg, #ecf2fe, #e8eeff); color: #0052d9; border-color: #c5d4f7; } */
.tag-customer {
background: linear-gradient(135deg, #ecf2fe, #e8eeff);
color: #0052d9;
border-color: #c5d4f7;
}
/* 助教标签 */
/* H5: .tag-coach { background: linear-gradient(135deg, #e8faf0, #e0f7ea); color: #00a870; border-color: #b3e6d0; } */
.tag-coach {
background: linear-gradient(135deg, #e8faf0, #e0f7ea);
color: #00a870;
border-color: #b3e6d0;
}
/* 时间 */
/* H5: text-xs text-gray-6 */
.note-time {
font-size: 24rpx;
color: #a6a6a6;
}

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "业绩明细",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,189 @@
import { mockPerformanceRecords } from '../../utils/mock-data'
import type { PerformanceRecord } from '../../utils/mock-data'
/** 按日期分组后的展示结构 */
interface DateGroup {
date: string
totalHours: string
totalIncome: string
records: RecordItem[]
}
interface RecordItem {
id: string
customerName: string
avatarChar: string
avatarGradient: string
timeRange: string
hours: string
courseType: string
courseTypeClass: string
location: string
income: string
}
const GRADIENT_POOL = [
'from-blue', 'from-pink', 'from-teal', 'from-green',
'from-orange', 'from-purple', 'from-violet', 'from-amber',
]
/** 根据名字首字生成稳定的渐变色 */
function nameToGradient(name: string): string {
const code = name.charCodeAt(0) || 0
return GRADIENT_POOL[code % GRADIENT_POOL.length]
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** Banner */
coachName: '小燕',
coachLevel: '星级',
storeName: '球会名称店',
/** 月份切换 */
currentYear: 2026,
currentMonth: 2,
monthLabel: '2026年2月',
canGoPrev: true,
canGoNext: false,
/** 统计概览 */
totalCount: '0笔',
totalHours: '0h',
totalIncome: '¥0',
/** 按日期分组的记录 */
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(() => {
// TODO: 替换为真实 API按月份请求
const allRecords = mockPerformanceRecords
// 模拟按日期分组的服务记录
const dateGroups: DateGroup[] = [
{
date: '2月7日',
totalHours: '6.0h',
totalIncome: '¥510',
records: [
{ id: 'r1', customerName: '王先生', avatarChar: '王', avatarGradient: nameToGradient('王'), timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: '¥160' },
{ id: 'r2', customerName: '李女士', avatarChar: '李', avatarGradient: nameToGradient('李'), timeRange: '16:00-18:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: '¥190' },
{ id: 'r3', customerName: '陈女士', avatarChar: '陈', avatarGradient: nameToGradient('陈'), timeRange: '10:00-12:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
],
},
{
date: '2月6日',
totalHours: '3.5h',
totalIncome: '¥280',
records: [
{ id: 'r4', customerName: '张先生', avatarChar: '张', avatarGradient: nameToGradient('张'), timeRange: '19:00-21:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: '¥160' },
{ id: 'r5', customerName: '刘先生', avatarChar: '刘', avatarGradient: nameToGradient('刘'), timeRange: '15:30-17:00', hours: '1.5h', courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: '¥120' },
],
},
{
date: '2月5日',
totalHours: '4.0h',
totalIncome: '¥320',
records: [
{ id: 'r6', customerName: '陈女士', avatarChar: '陈', avatarGradient: nameToGradient('陈'), timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
{ id: 'r7', customerName: '赵先生', avatarChar: '赵', avatarGradient: nameToGradient('赵'), 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: [
{ id: 'r8', customerName: '孙先生', avatarChar: '孙', avatarGradient: nameToGradient('孙'), timeRange: '19:00-21:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: '¥190' },
{ id: 'r9', customerName: '吴女士', avatarChar: '吴', avatarGradient: nameToGradient('吴'), timeRange: '15:00-17:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: '¥160' },
],
},
{
date: '2月3日',
totalHours: '3.5h',
totalIncome: '¥280',
records: [
{ id: 'r10', customerName: '郑先生', avatarChar: '郑', avatarGradient: nameToGradient('郑'), timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: '¥160' },
{ id: 'r11', customerName: '黄女士', avatarChar: '黄', avatarGradient: nameToGradient('黄'), timeRange: '14:30-16:00', hours: '1.5h', courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: '¥120' },
],
},
]
this.setData({
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
allRecords,
dateGroups,
totalCount: '32笔',
totalHours: '59.0h',
totalIncome: '¥4,720',
hasMore: false,
})
cb?.()
}, 500)
},
/** 切换月份 */
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,104 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<block wx:else>
<!-- Banner -->
<view class="banner-section">
<view class="banner-bg"></view>
<view class="banner-content">
<view class="coach-info">
<view class="coach-avatar">
<text class="avatar-emoji">👤</text>
</view>
<view class="coach-meta">
<view class="coach-name-row">
<text class="coach-name">{{coachName}}</text>
<text class="coach-level-tag">{{coachLevel}}</text>
</view>
<text class="coach-store">{{storeName}}</text>
</view>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" 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'}}" 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">{{totalCount}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">总业绩时长</text>
<text class="stat-value stat-primary">{{totalHours}}</text>
<text class="stat-hint">预估</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">收入</text>
<text class="stat-value stat-success">{{totalIncome}}</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'}}">
<view class="records-card">
<block wx:for="{{dateGroups}}" wx:key="date">
<!-- 日期分隔线 -->
<view class="date-divider">
<text class="dd-date">{{item.date}}</text>
<view class="dd-line"></view>
<text class="dd-stats">时长 {{item.totalHours}} · 预估收入 {{item.totalIncome}}</text>
</view>
<!-- 该日期下的记录 -->
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="id">
<view class="record-avatar avatar-{{rec.avatarGradient}}">
<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>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{120}}" />
<dev-fab />

View File

@@ -0,0 +1,347 @@
/* ============================================
* 加载态 / 空态
* ============================================ */
.page-loading {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm);
color: var(--color-gray-6);
}
/* ============================================
* Banner
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
}
.banner-bg {
width: 100%;
height: 280rpx;
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
display: block;
}
.banner-content {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 16rpx 40rpx 32rpx;
}
.coach-info {
display: flex;
align-items: center;
gap: 24rpx;
margin-top: 16rpx;
}
.coach-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-emoji {
font-size: 56rpx;
line-height: 1;
}
.coach-meta {
flex: 1;
min-width: 0;
}
.coach-name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.coach-name {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
}
.coach-level-tag {
padding: 4rpx 16rpx;
background: rgba(251, 191, 36, 0.3);
color: #fef3c7;
border-radius: 24rpx;
font-size: var(--font-xs);
}
.coach-store {
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.7);
}
/* ============================================
* 月份切换
* ============================================ */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid var(--color-gray-2);
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn-disabled {
opacity: 0.3;
pointer-events: none;
}
.month-label {
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-gray-13);
}
/* ============================================
* 统计概览
* ============================================ */
.stats-overview {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid var(--color-gray-2);
}
.stat-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 20rpx;
color: var(--color-gray-6);
margin-bottom: 4rpx;
}
.stat-value {
font-size: 36rpx;
font-weight: 700;
color: var(--color-gray-13);
font-variant-numeric: tabular-nums;
}
.stat-primary {
color: var(--color-primary);
}
.stat-success {
color: var(--color-success);
}
.stat-hint {
font-size: 20rpx;
color: var(--color-warning);
margin-top: 2rpx;
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: var(--color-gray-2);
margin-top: 4rpx;
}
/* ============================================
* 记录列表
* ============================================ */
.records-container {
padding: 24rpx;
}
.records-card {
background: #ffffff;
border-radius: 32rpx;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.date-divider {
display: flex;
align-items: center;
gap: 12rpx;
padding: 20rpx 32rpx 8rpx;
}
.dd-date {
font-size: 22rpx;
color: var(--color-gray-7);
font-weight: 500;
white-space: nowrap;
}
.dd-line {
flex: 1;
height: 2rpx;
background: var(--color-gray-4);
}
.dd-stats {
font-size: 22rpx;
color: var(--color-gray-6);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 32rpx;
}
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: var(--font-sm);
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); }
.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: var(--font-sm);
font-weight: 500;
color: var(--color-gray-13);
flex-shrink: 0;
}
.record-time {
font-size: var(--font-xs);
color: var(--color-gray-6);
}
.record-hours {
font-size: var(--font-sm);
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.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;
}
.tag-basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
background: #fffbeb;
color: #a16207;
}
.record-location {
font-size: var(--font-xs);
color: var(--color-gray-7);
}
.record-income {
font-size: 22rpx;
color: var(--color-gray-5);
flex-shrink: 0;
}
.record-income-val {
font-weight: 500;
color: var(--color-gray-9);
}

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "业绩详情",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"metric-card": "/components/metric-card/metric-card",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,179 @@
// TODO: 联调时替换为真实 API 调用
/** 业绩明细项(本月/上月) */
interface IncomeItem {
icon: string
label: string
desc: string
value: string
}
/** 服务记录(按日期分组后的展示结构) */
interface ServiceRecord {
customerName: string
avatarChar: string
avatarGradient: string
timeRange: string
hours: string
courseType: string
courseTypeClass: string
location: string
income: string
}
interface DateGroup {
date: string
records: ServiceRecord[]
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | '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; gradient: string; lastService: string; count: number }>,
newCustomerExpanded: false,
/** 常客列表 */
regularCustomers: [] as Array<{ name: string; avatarChar: string; gradient: string; hours: number; income: string; count: number }>,
regularCustomerExpanded: false,
},
onLoad() {
this.loadData()
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// 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 = [
'from-blue', 'from-pink', 'from-teal', 'from-green',
'from-orange', 'from-purple', 'from-violet', 'from-amber',
]
// 模拟服务记录按日期分组
const thisMonthRecords: DateGroup[] = [
{
date: '2月7日',
records: [
{ customerName: '王先生', avatarChar: '王', avatarGradient: gradients[0], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: '¥160' },
{ customerName: '李女士', avatarChar: '李', avatarGradient: gradients[1], timeRange: '16:00-18:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: '¥190' },
],
},
{
date: '2月6日',
records: [
{ customerName: '张先生', avatarChar: '张', avatarGradient: gradients[2], timeRange: '19:00-21:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: '¥160' },
],
},
{
date: '2月5日',
records: [
{ customerName: '陈女士', avatarChar: '陈', avatarGradient: gradients[2], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
{ customerName: '赵先生', avatarChar: '赵', avatarGradient: gradients[5], timeRange: '14:00-16:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: '¥160' },
],
},
{
date: '2月4日',
records: [
{ customerName: '孙先生', avatarChar: '孙', avatarGradient: gradients[6], timeRange: '19:00-21:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: '¥190' },
{ customerName: '吴女士', avatarChar: '吴', avatarGradient: gradients[2], timeRange: '15:00-17:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: '¥160' },
],
},
]
const newCustomers = [
{ name: '王先生', avatarChar: '王', gradient: gradients[0], lastService: '2月7日', count: 2 },
{ name: '李女士', avatarChar: '李', gradient: gradients[1], lastService: '2月7日', count: 1 },
{ name: '刘先生', avatarChar: '刘', gradient: gradients[4], lastService: '2月6日', count: 1 },
]
const regularCustomers = [
{ name: '张先生', avatarChar: '张', gradient: gradients[2], hours: 12, income: '¥960', count: 6 },
{ name: '陈女士', avatarChar: '陈', gradient: gradients[2], hours: 10, income: '¥800', count: 5 },
{ name: '赵先生', avatarChar: '赵', gradient: gradients[5], hours: 8, income: '¥640', count: 4 },
{ name: '孙先生', avatarChar: '孙', gradient: gradients[6], hours: 6, income: '¥570', count: 3 },
]
this.setData({
pageState: 'normal',
incomeItems,
thisMonthRecords,
newCustomers,
regularCustomers,
})
}, 500)
},
/** 展开/收起本月服务记录 */
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' }),
})
},
/** 点击收入概览卡片 → 跳转业绩记录 */
onIncomeCardTap() {
wx.navigateTo({ url: '/pages/performance-records/performance-records' })
},
})

View File

@@ -0,0 +1,272 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<block wx:elif="{{pageState === 'empty'}}">
<view class="page-empty">
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无业绩数据</text>
</view>
</block>
<block wx:else>
<!-- Banner -->
<view class="banner-section">
<!-- banner 背景用 CSS 渐变替代图片 -->
<view class="banner-bg-gradient"></view>
<view class="banner-content">
<!-- 个人信息 -->
<view class="coach-info">
<view class="coach-avatar">
<text class="avatar-emoji">👤</text>
</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" bindtap="onIncomeCardTap">
<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 class="dd-date">{{item.date}}</text>
</view>
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName">
<view class="record-avatar avatar-{{rec.avatarGradient}}">
<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-toggle" bindtap="toggleThisMonthRecords" wx:if="{{thisMonthRecords.length > visibleRecordGroups}}">
<text>{{thisMonthRecordsExpanded ? '收起' : '展开更多'}}</text>
<t-icon name="{{thisMonthRecordsExpanded ? 'chevron-up' : 'chevron-down'}}" size="28rpx" />
</view>
<view class="records-view-all" bindtap="goToRecords">
<text>查看全部</text>
<t-icon name="chevron-right" size="32rpx" color="#0052d9" />
</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"
wx:for="{{newCustomers}}"
wx:key="name"
wx:if="{{newCustomerExpanded || index < 2}}"
data-name="{{item.name}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.gradient}}">
<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="32rpx" color="#c5c5c5" />
</view>
</view>
<view class="toggle-btn" bindtap="toggleNewCustomer" wx:if="{{newCustomers.length > 2}}">
<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"
wx:for="{{regularCustomers}}"
wx:key="name"
wx:if="{{regularCustomerExpanded || index < 2}}"
data-name="{{item.name}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.gradient}}">
<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="32rpx" color="#c5c5c5" />
</view>
</view>
<view class="toggle-btn" bindtap="toggleRegularCustomer" wx:if="{{regularCustomers.length > 2}}">
<text>{{regularCustomerExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
</view>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{120}}" />
<dev-fab />

View File

@@ -0,0 +1,657 @@
/* ============================================
* 加载态 / 空态
* ============================================ */
.page-loading {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm);
color: var(--color-gray-6);
}
/* ============================================
* Banner
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
}
.banner-bg-gradient {
width: 100%;
height: 480rpx;
background: linear-gradient(135deg, #0052d9, #0080ff);
display: block;
}
.banner-content {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 24rpx 40rpx 32rpx;
}
.coach-info {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 40rpx;
margin-top: 16rpx;
}
.coach-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-emoji {
font-size: 56rpx;
line-height: 1;
}
.coach-meta {
flex: 1;
}
.coach-name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.coach-name {
font-size: 40rpx;
font-weight: 600;
color: #ffffff;
}
.coach-role-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
font-size: var(--font-xs);
color: #ffffff;
}
.coach-store {
font-size: var(--font-sm);
color: rgba(255, 255, 255, 0.7);
}
/* 收入概览卡片 */
.income-overview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.income-card {
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
padding: 24rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.income-label {
display: block;
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.85);
margin-bottom: 8rpx;
}
.income-value {
display: block;
font-size: var(--font-2xl);
font-weight: 700;
color: #ffffff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
.income-highlight {
color: #a7f3d0;
}
/* ============================================
* 通用 Section 卡片
* ============================================ */
.section-card {
background: #ffffff;
border-radius: 32rpx;
padding: 32rpx;
margin: 24rpx 24rpx 0;
box-shadow: var(--shadow-lg);
}
.section-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-gray-13);
}
.title-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.dot-primary { background: var(--color-primary); }
.dot-success { background: var(--color-success); }
.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;
}
.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;
}
.tier-label {
font-size: var(--font-sm);
font-weight: 500;
}
.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;
}
.rate-unit {
font-size: var(--font-xs);
}
.rate-desc {
font-size: 20rpx;
margin-top: 4rpx;
display: block;
}
.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;
}
.upgrade-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.upgrade-emoji {
font-size: 36rpx;
}
.upgrade-label {
font-size: var(--font-xs);
color: var(--color-gray-9);
display: block;
}
.upgrade-hours {
font-size: var(--font-sm);
font-weight: 600;
color: #1d4ed8;
}
.upgrade-hours-num {
font-size: var(--font-base);
}
.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;
}
.bonus-value {
font-size: 36rpx;
font-weight: 700;
}
/* ============================================
* 本月业绩明细
* ============================================ */
.income-list {
margin-bottom: 16rpx;
}
.income-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 2rpx solid var(--color-gray-1);
}
.income-row:last-child {
border-bottom: none;
}
.income-row-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.income-icon-box {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background: var(--color-gray-1);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
}
.income-info {
display: flex;
flex-direction: column;
}
.income-item-label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-gray-13);
}
.income-item-desc {
font-size: 20rpx;
color: var(--color-gray-5);
margin-top: 4rpx;
}
.income-item-value {
font-size: var(--font-base);
font-weight: 700;
color: var(--color-gray-13);
font-variant-numeric: tabular-nums;
}
.income-total {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16rpx;
border-top: 2rpx solid var(--color-gray-1);
}
.total-label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-gray-7);
}
.total-value {
font-size: 40rpx;
font-weight: 700;
color: var(--color-success);
font-variant-numeric: tabular-nums;
}
/* ============================================
* 服务记录
* ============================================ */
.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: var(--font-sm);
}
.service-records-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-gray-13);
}
.date-divider {
padding: 20rpx 0 8rpx;
}
.dd-date {
font-size: 22rpx;
color: var(--color-gray-7);
font-weight: 500;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
}
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: var(--font-sm);
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); }
.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: var(--font-sm);
font-weight: 500;
color: var(--color-gray-13);
flex-shrink: 0;
}
.record-time {
font-size: var(--font-xs);
color: var(--color-gray-6);
}
.record-hours {
font-size: var(--font-sm);
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.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;
}
.tag-basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
background: #fffbeb;
color: #a16207;
}
.record-location {
font-size: var(--font-xs);
color: var(--color-gray-7);
}
.record-income {
font-size: 22rpx;
color: var(--color-gray-5);
flex-shrink: 0;
}
.record-income-val {
font-weight: 500;
color: var(--color-gray-9);
}
.records-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 16rpx 0;
font-size: var(--font-sm);
color: var(--color-gray-7);
}
.records-view-all {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 12rpx 0;
font-size: var(--font-sm);
color: var(--color-primary);
font-weight: 500;
}
/* ============================================
* 新客 / 常客列表
* ============================================ */
.customer-list {
margin-bottom: 8rpx;
}
.customer-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 2rpx solid var(--color-gray-1);
}
.customer-item:last-child {
border-bottom: none;
}
.customer-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: var(--font-sm);
font-weight: 500;
flex-shrink: 0;
}
.customer-info {
flex: 1;
min-width: 0;
}
.customer-name {
display: block;
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-gray-13);
}
.customer-detail {
display: block;
font-size: var(--font-xs);
color: var(--color-gray-6);
margin-top: 4rpx;
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 12rpx 0;
font-size: var(--font-sm);
color: var(--color-gray-7);
}

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.getSystemInfoSync()
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,11 @@
{
"navigationBarTitleText": "任务详情",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"note-modal": "/components/note-modal/note-modal",
"star-rating": "/components/star-rating/star-rating",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,94 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
detail: null as TaskDetail | null,
sortedNotes: [] as Note[],
noteModalVisible: false,
/** 回访专属:话术列表 */
talkingPoints: [
'赵姐您好!上次打球感觉怎么样?新到的球杆手感还习惯吗?这周末您有空的话,可以提前帮您预留老位置~',
'赵姐,最近店里新进了一批斯诺克专用巧克粉,手感特别好,下次来的时候可以试试~',
'赵姐好呀,上次您说想学几个高级杆法,我最近整理了一些教学视频,要不要发给您先看看?',
'赵姐这周六下午VIP包厢有空位要不要帮您提前预留可以叫上朋友一起来打球~',
'赵姐您好,我们下个月有个会员积分兑换活动,您的积分可以换不少好东西,到时候提醒您哦~',
],
/** 近期服务记录 */
serviceRecords: [
{ table: 'VIP2号房', type: '基础课', typeClass: 'basic', duration: '2.0h', income: '¥190', drinks: '🍷 红牛x2 花生米x1', date: '2026-02-04 15:00' },
{ table: '8号台', type: '激励课', typeClass: 'incentive', duration: '1.5h', income: '¥120', drinks: '🍷 可乐x2', date: '2026-01-30 16:30' },
{ table: 'VIP2号房', type: '基础课', typeClass: 'basic', duration: '2.5h', income: '¥200', drinks: '🍷 百威x3 薯条x1', date: '2026-01-25 14:00' },
],
},
onLoad(options) {
const id = options?.id || ''
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0]
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const sorted = sortByTimestamp(detail.notes || []) as Note[]
this.setData({ pageState: 'normal', detail, sortedNotes: sorted })
}, 500)
},
onAddNote() {
this.setData({ noteModalVisible: true })
},
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { score, 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 })
},
/** 问问助手 */
onAskAssistant() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 记录回访 */
onRecordCallback() {
this.setData({ noteModalVisible: true })
},
onBack() {
wx.navigateBack()
},
/** 查看全部服务记录 */
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' }),
})
},
})

View File

@@ -0,0 +1,203 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到任务信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner - 青绿色主题 -->
<view class="banner-area banner-teal">
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">任务详情</text>
<view class="nav-placeholder"></view>
</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">客户回访</text>
</view>
<view class="sub-info">
<text class="phone">135****6677</text>
</view>
</view>
</view>
</view>
<view class="main-content">
<!-- ① 维客线索(回访页面排第一) -->
<view class="card">
<view class="card-header">
<text class="section-title title-green">维客线索</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="clue-list">
<view class="clue-item">
<view class="clue-tag clue-tag-primary">客户<text>\n</text>基础</view>
<view class="clue-body">
<text class="clue-text">👩 女性 · VIP会员 · 入会1年半 · 忠实老客户</text>
<text class="clue-source">By:系统</text>
</view>
</view>
<view class="clue-item">
<view class="clue-tag clue-tag-success">消费<text>\n</text>习惯</view>
<view class="clue-body">
<text class="clue-text">☀️ 偏好周末下午 · 月均6-8次</text>
<text class="clue-source">By:系统</text>
</view>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-success">消费<text>\n</text>习惯</view>
<view class="clue-body">
<text class="clue-text">💰 高客单价 · 爱点酒水</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">场均消费 ¥420高于门店均值 ¥180酒水小食附加消费占比 40%偏好VIP包厢</text>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-purple">玩法<text>\n</text>偏好</view>
<view class="clue-body">
<text class="clue-text">🎱 斯诺克爱好者 · 技术中上 · 喜欢研究杆法</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">斯诺克占比 85%,偶尔玩中式八球;对高级杆法有浓厚兴趣</text>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-error">重要<text>\n</text>反馈</view>
<view class="clue-body">
<text class="clue-text">⭐ 上次服务好评,新球杆手感满意</text>
<text class="clue-source">By:小燕</text>
</view>
<text class="clue-detail">2月4日到店时表示新球杆手感很好希望下次能提前预留VIP包厢</text>
</view>
</view>
</view>
<!-- ② 与我的关系(含近期服务记录) -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">与我的关系</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="relationship-row">
<view class="rel-tag rel-tag-pink">
<heart-icon score="{{detail.heartScore}}" />
<text>{{detail.heartScore > 8.5 ? '非常好' : detail.heartScore > 7 ? '良好' : detail.heartScore > 5 ? '一般' : '待发展'}}</text>
</view>
<view class="rel-bar">
<view class="rel-bar-fill" style="width: {{detail.heartScore * 10}}%"></view>
</view>
<text class="rel-score">{{fmt.toFixed(detail.heartScore / 10, 2)}}</text>
</view>
<text class="card-desc">长期合作关系良好,共有 45 次服务记录。客户多次指定您服务,评价均为 5 星。</text>
<!-- 近期服务记录(嵌入关系卡片内) -->
<view class="svc-section">
<text class="svc-title">📋 近期服务记录</text>
<view class="svc-record" wx:for="{{serviceRecords}}" wx:key="date">
<view class="svc-row-top">
<view class="svc-tags">
<text class="svc-table">{{item.table}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-duration">{{item.duration}}</text>
</view>
<text class="svc-income">{{item.income}}</text>
</view>
<view class="svc-row-bottom">
<text class="svc-drinks">{{item.drinks}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
</view>
<view class="svc-more" bindtap="onViewAllRecords">
<text>查看全部服务记录 →</text>
</view>
</view>
</view>
<!-- ③ 任务建议 -->
<view class="card">
<text class="section-title title-purple">任务建议</text>
<view class="suggestion-box suggestion-teal">
<view class="suggestion-header">
<text class="suggestion-icon">📞 常规回访要点</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<text class="suggestion-desc">该客户上次到店是 3 天前,关系良好,进行常规关怀回访:</text>
<view class="suggestion-list">
<text class="suggestion-item">• 询问上次体验是否满意,是否有改进建议</text>
<text class="suggestion-item">• 告知近期新到的斯诺克相关设备或活动</text>
<text class="suggestion-item">• 提前预约下次到店时间,提供专属服务</text>
</view>
</view>
<!-- 话术参考(竖线样式) -->
<view class="talking-section">
<view class="talking-header">
<text class="talking-title">💬 话术参考</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="talking-list">
<text class="talking-item" wx:for="{{talkingPoints}}" wx:key="index">"{{item}}"</text>
</view>
</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">
<text class="note-date">{{item.createdAt}}</text>
<text class="note-tag-inline {{item.tagType === 'coach' ? 'tag-coach-inline' : 'tag-customer-inline'}}">{{item.tagLabel}}</text>
</view>
<text class="note-content">{{item.content}}</text>
</view>
</block>
<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-ask btn-ask-teal" bindtap="onAskAssistant">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<note-modal
visible="{{noteModalVisible}}"
customerName="{{detail.customerName}}"
initialScore="{{0}}"
initialContent=""
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<ai-float-button bottom="{{200}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,458 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* Banner - 青绿色 */
.banner-area {
position: relative;
color: #ffffff;
padding-bottom: 48rpx;
}
.banner-teal {
background: linear-gradient(135deg, #14b8a6 0%, #06b6d4 100%);
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back { padding: 8rpx; }
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
}
.nav-placeholder { width: 48rpx; }
.customer-info {
display: flex;
align-items: center;
gap: 32rpx;
padding: 16rpx 40rpx 0;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
}
.info-right { flex: 1; }
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.customer-name {
font-size: var(--font-xl, 40rpx);
font-weight: 600;
}
.task-type-tag {
font-size: var(--font-xs, 24rpx);
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 100rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 32rpx;
}
.phone {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.7);
}
/* 主体 */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* 卡片 */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0,0,0,0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.title-green { border-left: 6rpx solid #00a870; padding-left: 16rpx; }
.title-blue { border-left: 6rpx solid #0052d9; padding-left: 16rpx; }
.title-purple { border-left: 6rpx solid #7c3aed; padding-left: 16rpx; margin-bottom: 32rpx; }
.ai-badge {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 100rpx;
}
/* 维客线索 */
.clue-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.clue-item {
display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 24rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.clue-item-detail {
flex-wrap: wrap;
}
.clue-tag {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
border-radius: 8rpx;
text-align: center;
line-height: 1.2;
}
.clue-tag-primary { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
.clue-tag-success { background: rgba(0, 168, 112, 0.1); color: #00a870; }
.clue-tag-purple { background: rgba(124, 58, 237, 0.1); color: #7c3aed; }
.clue-tag-error { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
.clue-body {
flex: 1;
min-height: 80rpx;
position: relative;
}
.clue-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-13, #242424);
line-height: 1.5;
}
.clue-source {
position: absolute;
bottom: 0;
right: 0;
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
background: var(--color-gray-1, #f3f3f3);
padding-left: 8rpx;
}
.clue-detail {
width: 100%;
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-top: 8rpx;
padding-left: 104rpx;
}
/* 关系区域 */
.relationship-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.rel-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 24rpx;
border-radius: 24rpx;
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: #ffffff;
flex-shrink: 0;
}
.rel-tag-pink { background: linear-gradient(135deg, #ec4899, #f43f5e); }
.rel-bar {
flex: 1;
height: 12rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: 100rpx;
overflow: hidden;
}
.rel-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9a8d4, #f43f5e);
border-radius: 100rpx;
}
.rel-score {
font-size: var(--font-lg, 36rpx);
font-weight: 700;
color: #ec4899;
}
.card-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-bottom: 24rpx;
}
/* 近期服务记录 */
.svc-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
margin-top: 8rpx;
}
.svc-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
margin-bottom: 24rpx;
display: block;
}
.svc-record {
padding: 16rpx 0;
border-bottom: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.svc-record:last-of-type { border-bottom: none; }
.svc-row-top, .svc-row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.svc-row-top { margin-bottom: 8rpx; }
.svc-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.svc-table {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-9, #5e5e5e);
background: #ffffff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.svc-type {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
color: #ffffff;
}
.svc-type-basic { background: #0052d9; }
.svc-type-incentive { background: #ed7b2f; }
.svc-duration {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.svc-income {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.svc-drinks {
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
}
.svc-date {
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
}
.svc-more {
text-align: center;
margin-top: 24rpx;
}
.svc-more text {
font-size: var(--font-xs, 24rpx);
color: var(--color-primary, #0052d9);
font-weight: 500;
}
/* 任务建议 */
.suggestion-box {
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid;
margin-bottom: 32rpx;
}
.suggestion-teal {
background: linear-gradient(135deg, #f0fdfa, #ecfeff);
border-color: #99f6e4;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.suggestion-icon {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: #0d9488;
}
.suggestion-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
margin-bottom: 16rpx;
display: block;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.suggestion-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
/* 话术参考(竖线样式) */
.talking-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.talking-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.talking-title {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-gray-13, #242424);
}
.talking-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.talking-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
padding-left: 24rpx;
border-left: 4rpx solid rgba(0, 82, 217, 0.3);
}
/* 备注 */
.note-count {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-item {
padding: 28rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
margin-bottom: 24rpx;
}
.note-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.note-date {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-content {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
.note-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-ask {
flex: 1;
height: 88rpx;
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}
.btn-ask-teal {
background: linear-gradient(135deg, #14b8a6, #06b6d4);
box-shadow: 0 8rpx 24rpx rgba(20, 184, 166, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}

View File

@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "任务详情",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"note-modal": "/components/note-modal/note-modal",
"star-rating": "/components/star-rating/star-rating",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,92 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
detail: null as TaskDetail | null,
sortedNotes: [] as Note[],
noteModalVisible: false,
/** 话术列表(竖线样式) */
talkingPoints: [
'张哥,好久没见您来打球了,最近忙吗?店里这周六有个球友聚会活动,想邀请您来玩,顺便认识一些新球友~',
'张哥好呀,最近工作还顺利吧?周末有空的话过来放松一下,我帮您约几个水平差不多的球友一起切磋~',
'张哥,店里最近新上了几款精酿啤酒,打完球来一杯特别爽,周末要不要来试试?',
'张哥,上次您说想练练组合球,我最近研究了几个不错的训练方法,下次来的时候教您~',
'张哥您好,这个月会员充值有额外赠送活动,力度挺大的,要不要了解一下?',
],
/** 近期服务记录 */
serviceRecords: [
{ table: '5号台', type: '基础课', typeClass: 'basic', duration: '2.0h', income: '¥160', drinks: '🍷 雪花x2 矿泉水x1', date: '2026-02-06 19:00' },
{ table: 'A08号台', type: '激励课', typeClass: 'incentive', duration: '1.5h', income: '¥150', drinks: '🍷 百威x1', date: '2026-01-20 20:30' },
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: '2.0h', income: '¥160', drinks: '🍷 可乐x2 红牛x1', date: '2026-01-05 21:00' },
],
},
onLoad(options) {
const id = options?.id || ''
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[1]
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const sorted = sortByTimestamp(detail.notes || []) as Note[]
this.setData({ pageState: 'normal', detail, sortedNotes: sorted })
}, 500)
},
onAddNote() {
this.setData({ noteModalVisible: true })
},
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { score, 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 })
},
onAskAssistant() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 记录联系 */
onRecordContact() {
this.setData({ noteModalVisible: true })
},
onBack() {
wx.navigateBack()
},
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' }),
})
},
})

View File

@@ -0,0 +1,204 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到任务信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner - 橙色主题 -->
<view class="banner-area banner-orange">
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">任务详情</text>
<view class="nav-placeholder"></view>
</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">优先召回</text>
</view>
<view class="sub-info">
<text class="phone">139****1234</text>
<block wx:if="{{detail.daysAbsent}}">
<text class="absent-info">⚠️ {{detail.daysAbsent}}天未到店</text>
</block>
</view>
</view>
</view>
</view>
<view class="main-content">
<!-- ① 维客线索 -->
<view class="card">
<view class="card-header">
<text class="section-title title-green">维客线索</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="clue-list">
<view class="clue-item">
<view class="clue-tag clue-tag-primary">客户<text>\n</text>基础</view>
<view class="clue-body">
<text class="clue-text">👤 普通会员 · 注册10个月 · 近期活跃度下降</text>
<text class="clue-source">By:系统</text>
</view>
</view>
<view class="clue-item">
<view class="clue-tag clue-tag-success">消费<text>\n</text>习惯</view>
<view class="clue-body">
<text class="clue-text">🌙 偏好夜场 20:00-23:00 · 之前月均3-4次</text>
<text class="clue-source">By:系统</text>
</view>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-success">消费<text>\n</text>习惯</view>
<view class="clue-body">
<text class="clue-text">📉 频率下降 · 爱组局</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">场均消费 ¥220近月到店仅 1 次(之前月均 3-4 次);喜欢和朋友组局</text>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-purple">玩法<text>\n</text>偏好</view>
<view class="clue-body">
<text class="clue-text">🎱 中式八球为主 · 喜欢组局对战 · 想练组合球</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">中式八球占比 90%,技术水平中等;喜欢 3-4 人组局对战</text>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-error">重要<text>\n</text>反馈</view>
<view class="clue-body">
<text class="clue-text">⚠️ 换了工作,下班时间不固定</text>
<text class="clue-source">By:小燕</text>
</view>
<text class="clue-detail">2月3日沟通时提到最近换了工作周末可能更方便</text>
</view>
</view>
</view>
<!-- ② 与我的关系(含近期服务记录) -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">与我的关系</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="relationship-row">
<view class="rel-tag rel-tag-amber">
<heart-icon score="{{detail.heartScore}}" />
<text>{{detail.heartScore > 8.5 ? '非常好' : detail.heartScore > 7 ? '良好' : detail.heartScore > 5 ? '一般' : '待发展'}}</text>
</view>
<view class="rel-bar">
<view class="rel-bar-fill rel-bar-amber" style="width: {{detail.heartScore * 10}}%"></view>
</view>
<text class="rel-score rel-score-amber">{{fmt.toFixed(detail.heartScore / 10, 2)}}</text>
</view>
<text class="card-desc">最近 2 个月互动较少,仅有 3 次服务记录。客户对您的印象中等,有提升空间。</text>
<view class="svc-section">
<text class="svc-title">📋 近期服务记录</text>
<view class="svc-record" wx:for="{{serviceRecords}}" wx:key="date">
<view class="svc-row-top">
<view class="svc-tags">
<text class="svc-table">{{item.table}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-duration">{{item.duration}}</text>
</view>
<text class="svc-income">{{item.income}}</text>
</view>
<view class="svc-row-bottom">
<text class="svc-drinks">{{item.drinks}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
</view>
<view class="svc-more" bindtap="onViewAllRecords">
<text>查看全部服务记录 →</text>
</view>
</view>
</view>
<!-- ③ 任务建议 -->
<view class="card">
<text class="section-title title-orange">任务建议</text>
<view class="suggestion-box suggestion-orange">
<view class="suggestion-header">
<text class="suggestion-icon">💡 建议执行</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<text class="suggestion-desc">该客户消费频率从月均 4 次下降到近月仅 1 次,需要关注原因:</text>
<view class="suggestion-list">
<text class="suggestion-item">• 了解是否工作变动或搬家导致不便</text>
<text class="suggestion-item">• 询问对门店服务是否有改进建议</text>
<text class="suggestion-item">• 推荐近期的会员优惠活动</text>
</view>
</view>
<view class="talking-section">
<view class="talking-header">
<text class="talking-title">💬 话术参考</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="talking-list">
<text class="talking-item" wx:for="{{talkingPoints}}" wx:key="index">"{{item}}"</text>
</view>
</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">
<text class="note-date">{{item.createdAt}}</text>
<text class="note-tag-inline {{item.tagType === 'coach' ? 'tag-coach-inline' : 'tag-customer-inline'}}">{{item.tagLabel}}</text>
</view>
<text class="note-content">{{item.content}}</text>
</view>
</block>
<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-ask btn-ask-orange" bindtap="onAskAssistant">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<note-modal
visible="{{noteModalVisible}}"
customerName="{{detail.customerName}}"
initialScore="{{0}}"
initialContent=""
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<ai-float-button bottom="{{200}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,459 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* Banner - 橙色 */
.banner-area {
position: relative;
color: #ffffff;
padding-bottom: 48rpx;
}
.banner-orange {
background: linear-gradient(135deg, #f97316 0%, #f59e0b 100%);
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back { padding: 8rpx; }
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
}
.nav-placeholder { width: 48rpx; }
.customer-info {
display: flex;
align-items: center;
gap: 32rpx;
padding: 16rpx 40rpx 0;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
}
.info-right { flex: 1; }
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.customer-name {
font-size: var(--font-xl, 40rpx);
font-weight: 600;
}
.task-type-tag {
font-size: var(--font-xs, 24rpx);
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 100rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 32rpx;
}
.phone {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.7);
}
.absent-info {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.9);
}
/* 主体 */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* 卡片 */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0,0,0,0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.title-green { border-left: 6rpx solid #00a870; padding-left: 16rpx; }
.title-blue { border-left: 6rpx solid #0052d9; padding-left: 16rpx; }
.title-orange { border-left: 6rpx solid #ed7b2f; padding-left: 16rpx; margin-bottom: 32rpx; }
.ai-badge {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 100rpx;
}
/* 维客线索 */
.clue-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.clue-item {
display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 24rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.clue-item-detail { flex-wrap: wrap; }
.clue-tag {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
border-radius: 8rpx;
text-align: center;
line-height: 1.2;
}
.clue-tag-primary { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
.clue-tag-success { background: rgba(0, 168, 112, 0.1); color: #00a870; }
.clue-tag-purple { background: rgba(124, 58, 237, 0.1); color: #7c3aed; }
.clue-tag-error { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
.clue-body {
flex: 1;
min-height: 80rpx;
position: relative;
}
.clue-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-13, #242424);
line-height: 1.5;
}
.clue-source {
position: absolute;
bottom: 0;
right: 0;
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
background: var(--color-gray-1, #f3f3f3);
padding-left: 8rpx;
}
.clue-detail {
width: 100%;
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-top: 8rpx;
padding-left: 104rpx;
}
/* 关系区域 - 琥珀色变体 */
.relationship-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.rel-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 24rpx;
border-radius: 24rpx;
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: #ffffff;
flex-shrink: 0;
}
.rel-tag-amber { background: linear-gradient(135deg, #f59e0b, #eab308); }
.rel-bar {
flex: 1;
height: 12rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: 100rpx;
overflow: hidden;
}
.rel-bar-fill {
height: 100%;
border-radius: 100rpx;
}
.rel-bar-amber { background: linear-gradient(90deg, #fcd34d, #eab308); }
.rel-score {
font-size: var(--font-lg, 36rpx);
font-weight: 700;
}
.rel-score-amber { color: #f59e0b; }
.card-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-bottom: 24rpx;
}
/* 近期服务记录 */
.svc-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
margin-top: 8rpx;
}
.svc-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
margin-bottom: 24rpx;
display: block;
}
.svc-record {
padding: 16rpx 0;
border-bottom: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.svc-record:last-of-type { border-bottom: none; }
.svc-row-top, .svc-row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.svc-row-top { margin-bottom: 8rpx; }
.svc-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.svc-table {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-9, #5e5e5e);
background: #ffffff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.svc-type {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
color: #ffffff;
}
.svc-type-basic { background: #0052d9; }
.svc-type-incentive { background: #ed7b2f; }
.svc-duration {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.svc-income {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.svc-drinks {
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
}
.svc-date {
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
}
.svc-more {
text-align: center;
margin-top: 24rpx;
}
.svc-more text {
font-size: var(--font-xs, 24rpx);
color: var(--color-primary, #0052d9);
font-weight: 500;
}
/* 任务建议 - 橙色 */
.suggestion-box {
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid;
margin-bottom: 32rpx;
}
.suggestion-orange {
background: linear-gradient(135deg, #fff7ed, #fffbeb);
border-color: #fed7aa;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.suggestion-icon {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: #ed7b2f;
}
.suggestion-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
margin-bottom: 16rpx;
display: block;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.suggestion-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
/* 话术参考(竖线样式) */
.talking-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.talking-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.talking-title {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-gray-13, #242424);
}
.talking-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.talking-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
padding-left: 24rpx;
border-left: 4rpx solid rgba(0, 82, 217, 0.3);
}
/* 备注 */
.note-count {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-item {
padding: 28rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
margin-bottom: 24rpx;
}
.note-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.note-date {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-content {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
.note-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-ask {
flex: 1;
height: 88rpx;
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}
.btn-ask-orange {
background: linear-gradient(135deg, #f97316, #f59e0b);
box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}

View File

@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "任务详情",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"note-modal": "/components/note-modal/note-modal",
"star-rating": "/components/star-rating/star-rating",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,92 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
detail: null as TaskDetail | null,
sortedNotes: [] as Note[],
noteModalVisible: false,
/** 话术列表(竖线样式) */
talkingPoints: [
'王哥您好,上次打球聊得挺开心的,最近有空来玩吗?可以约几个球友一起~',
'王哥,最近店里新到了几副好球杆,知道您是行家,有空来试试手感?',
'王哥好呀,周末有个中式八球友谊赛,奖品挺丰富的,要不要来参加?',
'王哥,上次您推荐的那个朋友来过了,打得不错,下次可以一起约~',
'王哥您好,我们最近推出了老带新活动,您带朋友来都有优惠,了解一下?',
],
/** 近期服务记录 */
serviceRecords: [
{ table: '6号台', type: '基础课', typeClass: 'basic', duration: '1.5h', income: '¥150', drinks: '🍷 可乐x2', date: '2026-02-08 15:00' },
{ table: 'VIP1号房', type: '激励课', typeClass: 'incentive', duration: '2.0h', income: '¥200', drinks: '🍷 红牛x1 花生米x1', date: '2026-02-01 16:00' },
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: '1.5h', income: '¥150', drinks: '🍷 矿泉水x2', date: '2026-01-25 14:30' },
],
},
onLoad(options) {
const id = options?.id || ''
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[2]
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const sorted = sortByTimestamp(detail.notes || []) as Note[]
this.setData({ pageState: 'normal', detail, sortedNotes: sorted })
}, 500)
},
onAddNote() {
this.setData({ noteModalVisible: true })
},
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { score, 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 })
},
onAskAssistant() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 记录互动 */
onRecordInteraction() {
this.setData({ noteModalVisible: true })
},
onBack() {
wx.navigateBack()
},
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' }),
})
},
})

View File

@@ -0,0 +1,193 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到任务信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner - 粉色主题 -->
<view class="banner-area banner-pink">
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">任务详情</text>
<view class="nav-placeholder"></view>
</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">关系构建</text>
</view>
<view class="sub-info">
<text class="phone">137****8899</text>
<block wx:if="{{detail.preferences.length > 0}}">
<text class="pref-info">🎱 {{detail.preferences[0]}}</text>
</block>
</view>
</view>
</view>
</view>
<view class="main-content">
<!-- ① 维客线索 -->
<view class="card">
<view class="card-header">
<text class="section-title title-green">维客线索</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="clue-list">
<view class="clue-item">
<view class="clue-tag clue-tag-primary">客户<text>\n</text>基础</view>
<view class="clue-body">
<text class="clue-text">👤 普通会员 · 注册8个月 · 社交活跃</text>
<text class="clue-source">By:系统</text>
</view>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-success">消费<text>\n</text>习惯</view>
<view class="clue-body">
<text class="clue-text">☀️ 偏好周末下午 · 月均3-4次 · 喜欢包厢</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">{{detail.consumptionHabits || '周末下午为主,偏好包厢'}}</text>
</view>
<view class="clue-item clue-item-detail">
<view class="clue-tag clue-tag-purple">玩法<text>\n</text>偏好</view>
<view class="clue-body">
<text class="clue-text">🎱 中式台球 · 麻将 · 喜欢组队</text>
<text class="clue-source">By:系统</text>
</view>
<text class="clue-detail">{{detail.socialPreference || '喜欢组队打球,社交型消费者'}}</text>
</view>
<view class="clue-item">
<view class="clue-tag clue-tag-error">重要<text>\n</text>反馈</view>
<view class="clue-body">
<text class="clue-text">⭐ 对服务满意,希望认识更多球友</text>
<text class="clue-source">By:小燕</text>
</view>
</view>
</view>
</view>
<!-- ② 与我的关系(含近期服务记录) -->
<view class="card">
<view class="card-header">
<text class="section-title title-blue">与我的关系</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="relationship-row">
<view class="rel-tag rel-tag-pink">
<heart-icon score="{{detail.heartScore}}" />
<text>{{detail.heartScore > 8.5 ? '非常好' : detail.heartScore > 7 ? '良好' : detail.heartScore > 5 ? '一般' : '待发展'}}</text>
</view>
<view class="rel-bar">
<view class="rel-bar-fill" style="width: {{detail.heartScore * 10}}%"></view>
</view>
<text class="rel-score">{{fmt.toFixed(detail.heartScore / 10, 2)}}</text>
</view>
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
<view class="svc-section">
<text class="svc-title">📋 近期服务记录</text>
<view class="svc-record" wx:for="{{serviceRecords}}" wx:key="date">
<view class="svc-row-top">
<view class="svc-tags">
<text class="svc-table">{{item.table}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-duration">{{item.duration}}</text>
</view>
<text class="svc-income">{{item.income}}</text>
</view>
<view class="svc-row-bottom">
<text class="svc-drinks">{{item.drinks}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
</view>
<view class="svc-more" bindtap="onViewAllRecords">
<text>查看全部服务记录 →</text>
</view>
</view>
</view>
<!-- ③ 任务建议 -->
<view class="card">
<text class="section-title title-pink-left">任务建议</text>
<view class="suggestion-box suggestion-pink">
<view class="suggestion-header">
<text class="suggestion-icon">💝 关系构建重点</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="suggestion-list">
<text class="suggestion-item" wx:for="{{detail.aiAnalysis.suggestions}}" wx:key="index">• {{item}}</text>
</view>
</view>
<view class="talking-section">
<view class="talking-header">
<text class="talking-title">💬 话术参考</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="talking-list">
<text class="talking-item" wx:for="{{talkingPoints}}" wx:key="index">"{{item}}"</text>
</view>
</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">
<text class="note-date">{{item.createdAt}}</text>
<text class="note-tag-inline {{item.tagType === 'coach' ? 'tag-coach-inline' : 'tag-customer-inline'}}">{{item.tagLabel}}</text>
</view>
<text class="note-content">{{item.content}}</text>
</view>
</block>
<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-ask btn-ask-pink" bindtap="onAskAssistant">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<note-modal
visible="{{noteModalVisible}}"
customerName="{{detail.customerName}}"
initialScore="{{0}}"
initialContent=""
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<ai-float-button bottom="{{200}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,443 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* Banner - 粉色 */
.banner-area {
position: relative;
color: #ffffff;
padding-bottom: 48rpx;
}
.banner-pink {
background: linear-gradient(135deg, #ec4899 0%, #f43f5e 100%);
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back { padding: 8rpx; }
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
}
.nav-placeholder { width: 48rpx; }
.customer-info {
display: flex;
align-items: center;
gap: 32rpx;
padding: 16rpx 40rpx 0;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
}
.info-right { flex: 1; }
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.customer-name {
font-size: var(--font-xl, 40rpx);
font-weight: 600;
}
.task-type-tag {
font-size: var(--font-xs, 24rpx);
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 100rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 32rpx;
}
.phone {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.7);
}
.pref-info {
font-size: var(--font-xs, 24rpx);
color: rgba(255, 255, 255, 0.9);
}
/* 主体 */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* 卡片 */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0,0,0,0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.title-green { border-left: 6rpx solid #00a870; padding-left: 16rpx; }
.title-blue { border-left: 6rpx solid #0052d9; padding-left: 16rpx; }
.title-pink-left { border-left: 6rpx solid #ec4899; padding-left: 16rpx; margin-bottom: 32rpx; }
.ai-badge {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 100rpx;
}
/* 维客线索 */
.clue-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.clue-item {
display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 24rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.clue-item-detail { flex-wrap: wrap; }
.clue-tag {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
border-radius: 8rpx;
text-align: center;
line-height: 1.2;
}
.clue-tag-primary { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
.clue-tag-success { background: rgba(0, 168, 112, 0.1); color: #00a870; }
.clue-tag-purple { background: rgba(124, 58, 237, 0.1); color: #7c3aed; }
.clue-tag-error { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
.clue-body {
flex: 1;
min-height: 80rpx;
position: relative;
}
.clue-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-13, #242424);
line-height: 1.5;
}
.clue-source {
position: absolute;
bottom: 0;
right: 0;
font-size: 22rpx;
color: var(--color-gray-6, #a6a6a6);
background: var(--color-gray-1, #f3f3f3);
padding-left: 8rpx;
}
.clue-detail {
width: 100%;
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-top: 8rpx;
padding-left: 104rpx;
}
/* 关系区域 */
.relationship-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.rel-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 24rpx;
border-radius: 24rpx;
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: #ffffff;
flex-shrink: 0;
}
.rel-tag-pink { background: linear-gradient(135deg, #ec4899, #f43f5e); }
.rel-bar {
flex: 1;
height: 12rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: 100rpx;
overflow: hidden;
}
.rel-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9a8d4, #f43f5e);
border-radius: 100rpx;
}
.rel-score {
font-size: var(--font-lg, 36rpx);
font-weight: 700;
color: #ec4899;
}
.card-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
margin-bottom: 24rpx;
}
/* 近期服务记录 */
.svc-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 24rpx;
margin-top: 8rpx;
}
.svc-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
margin-bottom: 24rpx;
display: block;
}
.svc-record {
padding: 16rpx 0;
border-bottom: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.svc-record:last-of-type { border-bottom: none; }
.svc-row-top, .svc-row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.svc-row-top { margin-bottom: 8rpx; }
.svc-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.svc-table {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-9, #5e5e5e);
background: #ffffff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.svc-type {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
color: #ffffff;
}
.svc-type-basic { background: #0052d9; }
.svc-type-incentive { background: #ed7b2f; }
.svc-duration {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.svc-income {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.svc-drinks { font-size: 22rpx; color: var(--color-gray-6, #a6a6a6); }
.svc-date { font-size: 22rpx; color: var(--color-gray-6, #a6a6a6); }
.svc-more { text-align: center; margin-top: 24rpx; }
.svc-more text {
font-size: var(--font-xs, 24rpx);
color: var(--color-primary, #0052d9);
font-weight: 500;
}
/* 任务建议 - 粉色 */
.suggestion-box {
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid;
margin-bottom: 32rpx;
}
.suggestion-pink {
background: linear-gradient(135deg, #fdf2f8, #fff1f2);
border-color: #fbcfe8;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.suggestion-icon {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: #ec4899;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.suggestion-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
/* 话术参考(竖线样式) */
.talking-section {
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid var(--color-gray-3, #e7e7e7);
}
.talking-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.talking-title {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-gray-13, #242424);
}
.talking-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.talking-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
padding-left: 24rpx;
border-left: 4rpx solid rgba(0, 82, 217, 0.3);
}
/* 备注 */
.note-count {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-item {
padding: 28rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
margin-bottom: 24rpx;
}
.note-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.note-date {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-content {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
.note-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-ask {
flex: 1;
height: 88rpx;
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}
.btn-ask-pink {
background: linear-gradient(135deg, #ec4899, #f43f5e);
box-shadow: 0 8rpx 24rpx rgba(236, 72, 153, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}

View File

@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "任务详情",
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"note-modal": "/components/note-modal/note-modal",
"star-rating": "/components/star-rating/star-rating",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,104 @@
import { mockTaskDetails } from '../../utils/mock-data'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 任务详情 */
detail: null as TaskDetail | null,
/** 排序后的备注列表 */
sortedNotes: [] as Note[],
/** 备注弹窗 */
noteModalVisible: false,
},
onLoad(options) {
const id = options?.id || ''
this.loadData(id)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0]
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const sorted = sortByTimestamp(detail.notes || []) as Note[]
this.setData({
pageState: 'normal',
detail,
sortedNotes: sorted,
})
}, 500)
},
/** 点击"添加备注" */
onAddNote() {
this.setData({ noteModalVisible: true })
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { score, 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 }),
}
const notes = [newNote, ...this.data.sortedNotes]
this.setData({ sortedNotes: notes })
},
/** 备注弹窗取消 */
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** 放弃任务 */
onAbandon() {
wx.showModal({
title: '放弃任务',
content: '确定要放弃该客户的维护吗?此操作不可撤销。',
confirmColor: '#e34d59',
success: (res) => {
if (res.confirm) {
wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
})
},
/** 问问助手 */
onAskAssistant() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 标记完成 */
onMarkComplete() {
wx.showToast({ title: '已标记完成', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500)
},
/** 返回 */
onBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,125 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到任务信息</text>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域 -->
<view class="banner-area banner-red">
<view class="banner-nav">
<view class="nav-back" bindtap="onBack">
<t-icon name="chevron-left" size="48rpx" color="#ffffff" />
</view>
<text class="nav-title">任务详情</text>
<text class="nav-abandon" bindtap="onAbandon">放弃</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">138****5678</text>
</view>
</view>
</view>
</view>
<!-- 主体内容 -->
<view class="main-content">
<!-- 与我的关系 -->
<view class="card">
<view class="card-header">
<text class="section-title title-pink">与我的关系</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="relationship-row">
<view class="rel-tag rel-tag-pink">
<heart-icon score="{{detail.heartScore}}" />
<text>{{detail.heartScore > 8.5 ? '非常好' : detail.heartScore > 7 ? '良好' : detail.heartScore > 5 ? '一般' : '待发展'}}</text>
</view>
<view class="rel-bar">
<view class="rel-bar-fill" style="width: {{detail.heartScore * 10}}%"></view>
</view>
<text class="rel-score">{{fmt.toFixed(detail.heartScore / 10, 2)}}</text>
</view>
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
</view>
<!-- 任务建议 -->
<view class="card">
<text class="section-title title-orange">任务建议</text>
<view class="suggestion-box">
<view class="suggestion-header">
<text class="suggestion-icon">💡 建议执行</text>
<text class="ai-badge">AI智能洞察</text>
</view>
<view class="suggestion-list">
<view class="suggestion-item" wx:for="{{detail.aiAnalysis.suggestions}}" wx:key="index">
<text>• {{item}}</text>
</view>
</view>
</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">
<text class="note-date">{{item.createdAt}}</text>
<text class="note-tag-inline {{item.tagType === 'coach' ? 'tag-coach-inline' : 'tag-customer-inline'}}">{{item.tagLabel}}</text>
</view>
<text class="note-content">{{item.content}}</text>
</view>
</block>
<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-ask" bindtap="onAskAssistant">
<t-icon name="chat" size="40rpx" color="#ffffff" />
<text>问问助手</text>
</view>
<view class="btn-note" bindtap="onAddNote">
<t-icon name="edit-1" size="40rpx" color="#242424" />
<text>备注</text>
</view>
</view>
<!-- 备注弹窗 -->
<note-modal
visible="{{noteModalVisible}}"
customerName="{{detail.customerName}}"
initialScore="{{0}}"
initialContent=""
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" customerId="{{detail.id}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,288 @@
/* 加载态 & 空态 */
.page-loading,
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.empty-text {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-6, #a6a6a6);
}
/* Banner */
.banner-area {
position: relative;
color: #ffffff;
padding-bottom: 48rpx;
}
.banner-red {
background: linear-gradient(135deg, #e34d59 0%, #c62828 100%);
}
.banner-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
}
.nav-back {
padding: 8rpx;
}
.nav-title {
font-size: var(--font-base, 32rpx);
font-weight: 500;
}
.nav-abandon {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.5);
}
/* 客户信息 */
.customer-info {
display: flex;
align-items: center;
gap: 32rpx;
padding: 16rpx 40rpx 0;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
}
.info-right {
flex: 1;
}
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.customer-name {
font-size: var(--font-xl, 40rpx);
font-weight: 600;
}
.task-type-tag {
font-size: var(--font-xs, 24rpx);
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 100rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 32rpx;
}
.phone {
font-size: var(--font-sm, 28rpx);
color: rgba(255, 255, 255, 0.7);
}
/* 主体内容 */
.main-content {
padding: 32rpx;
padding-bottom: 200rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* 卡片 */
.card {
background: #ffffff;
border-radius: var(--radius-xl, 32rpx);
padding: 40rpx;
box-shadow: var(--shadow-lg, 0 8rpx 32rpx rgba(0,0,0,0.06));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.section-title {
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.title-pink { border-left: 6rpx solid #ec4899; padding-left: 16rpx; }
.title-orange { border-left: 6rpx solid #ed7b2f; padding-left: 16rpx; margin-bottom: 32rpx; }
.title-blue { border-left: 6rpx solid #0052d9; padding-left: 16rpx; }
.title-green { border-left: 6rpx solid #00a870; padding-left: 16rpx; }
.ai-badge {
font-size: 22rpx;
color: var(--color-primary, #0052d9);
background: var(--color-primary-light, #ecf2fe);
padding: 4rpx 16rpx;
border-radius: 100rpx;
}
/* 关系区域 */
.relationship-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.rel-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 24rpx;
border-radius: 24rpx;
font-size: var(--font-sm, 28rpx);
font-weight: 600;
color: #ffffff;
flex-shrink: 0;
}
.rel-tag-pink {
background: linear-gradient(135deg, #ec4899, #f43f5e);
}
.rel-bar {
flex: 1;
height: 12rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: 100rpx;
overflow: hidden;
}
.rel-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9a8d4, #f43f5e);
border-radius: 100rpx;
}
.rel-score {
font-size: var(--font-lg, 36rpx);
font-weight: 700;
color: #ec4899;
}
.card-desc {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-8, #777777);
line-height: 1.6;
}
/* 任务建议 */
.suggestion-box {
background: linear-gradient(135deg, #eff6ff, #eef2ff);
border-radius: var(--radius-lg, 24rpx);
padding: 32rpx;
border: 1rpx solid #dbeafe;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.suggestion-icon {
font-size: var(--font-sm, 28rpx);
font-weight: 500;
color: var(--color-primary, #0052d9);
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.suggestion-item {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
/* 备注 */
.note-count {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-item {
padding: 28rpx;
background: var(--color-gray-1, #f3f3f3);
border-radius: var(--radius-lg, 24rpx);
border: 1rpx solid var(--color-gray-3, #e7e7e7);
margin-bottom: 24rpx;
}
.note-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.note-date {
font-size: var(--font-xs, 24rpx);
color: var(--color-gray-6, #a6a6a6);
}
.note-content {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
line-height: 1.6;
}
.note-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 16rpx;
}
.empty-hint {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-5, #c5c5c5);
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 128rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--color-gray-2, #eeeeee);
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 32rpx;
z-index: 100;
}
.btn-ask {
flex: 1;
height: 88rpx;
background: linear-gradient(135deg, #0052d9, #3b82f6);
color: #ffffff;
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 82, 217, 0.3);
}
.btn-note {
flex: 1;
height: 88rpx;
background: var(--color-gray-1, #f3f3f3);
color: var(--color-gray-13, #242424);
font-weight: 500;
border-radius: var(--radius-lg, 24rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: var(--font-base, 32rpx);
}

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "任务",
"enablePullDownRefresh": true,
"usingComponents": {
"banner": "/components/banner/banner",
"heart-icon": "/components/heart-icon/heart-icon",
"hobby-tag": "/components/hobby-tag/hobby-tag",
"ai-float-button": "/components/ai-float-button/ai-float-button"
}
}

View File

@@ -0,0 +1,123 @@
import { mockTasks, mockPerformance } from '../../utils/mock-data'
import type { Task } from '../../utils/mock-data'
import { getTaskTypeColor } from '../../utils/task'
/** 任务类型 → 详情页路由映射 */
const DETAIL_ROUTE_MAP: Record<string, string> = {
callback: '/pages/task-detail-callback/task-detail-callback',
priority_recall: '/pages/task-detail-priority/task-detail-priority',
relationship: '/pages/task-detail-relationship/task-detail-relationship',
}
/** 为任务附加颜色信息 */
function enrichTask(task: Task) {
return {
...task,
typeColor: getTaskTypeColor(task.taskType),
}
}
Page({
data: {
/** 页面状态loading / empty / normal */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 任务列表(附带颜色) */
tasks: [] as ReturnType<typeof enrichTask>[],
/** 任务总数 */
taskCount: 0,
/** Banner 指标 */
bannerMetrics: [] as Array<{ label: string; value: string }>,
/** Banner 标题 */
bannerTitle: '',
/** 是否还有更多数据(模拟分页) */
hasMore: true,
},
onLoad() {
this.loadData()
},
onShow() {
// TabBar 页面每次显示时可刷新
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData(() => {
wx.stopPullDownRefresh()
})
},
/** 触底加载更多 */
onReachBottom() {
if (!this.data.hasMore) return
// Mock无更多数据
this.setData({ hasMore: false })
wx.showToast({ title: '没有更多了', icon: 'none' })
},
/** 加载数据 */
loadData(cb?: () => void) {
this.setData({ pageState: 'loading' })
// 模拟网络延迟
setTimeout(() => {
const pending = mockTasks.filter((t) => t.status === 'pending')
const enriched = pending.map(enrichTask)
const perf = mockPerformance
const bannerTitle = `${perf.currentTier}`
const bannerMetrics = [
{ label: '本月收入', value: `¥${perf.monthlyIncome.toLocaleString()}` },
{ label: '今日服务', value: `${perf.todayServiceCount}` },
{ label: '距下一档', value: `¥${perf.nextTierGap.toLocaleString()}` },
]
this.setData({
pageState: enriched.length > 0 ? 'normal' : 'empty',
tasks: enriched,
taskCount: enriched.length,
bannerTitle,
bannerMetrics,
hasMore: true,
})
cb?.()
}, 600)
},
/** 点击任务卡片 → 跳转详情 */
onTaskTap(e: WechatMiniprogram.TouchEvent) {
const { id, tasktype } = e.currentTarget.dataset
const route = DETAIL_ROUTE_MAP[tasktype] || '/pages/task-detail/task-detail'
wx.navigateTo({
url: `${route}?id=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 长按任务卡片 → 操作菜单 */
onTaskLongPress(e: WechatMiniprogram.TouchEvent) {
const { id, name } = e.currentTarget.dataset
wx.showActionSheet({
itemList: ['查看详情', '标记完成', '添加备注'],
success: (res) => {
switch (res.tapIndex) {
case 0: {
// 查看详情 — 复用点击逻辑
const { tasktype } = e.currentTarget.dataset
const route = DETAIL_ROUTE_MAP[tasktype] || '/pages/task-detail/task-detail'
wx.navigateTo({ url: `${route}?id=${id}` })
break
}
case 1:
wx.showToast({ title: `已标记「${name}」完成`, icon: 'success' })
break
case 2:
wx.showToast({ title: `为「${name}」添加备注`, icon: 'none' })
break
}
},
})
},
})

View File

@@ -0,0 +1,88 @@
<!-- 任务列表页 -->
<view class="page-task-list">
<!-- ====== 顶部绩效 Banner ====== -->
<banner theme="blue" title="{{bannerTitle}}" metrics="{{bannerMetrics}}" />
<!-- ====== 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:elif="{{pageState === 'empty'}}">
<t-icon name="task" size="160rpx" color="#dcdcdc" />
<text class="empty-text">暂无待办任务</text>
</view>
<!-- ====== 正常态:任务列表 ====== -->
<view class="task-section" wx:elif="{{pageState === 'normal'}}">
<!-- 标题行 -->
<view class="section-header">
<text class="section-title">今日 客户维护</text>
<text class="section-count">共 {{taskCount}} 项</text>
</view>
<!-- 任务卡片列表 -->
<view
class="task-card"
wx:for="{{tasks}}"
wx:key="id"
data-id="{{item.id}}"
data-name="{{item.customerName}}"
data-tasktype="{{item.taskType}}"
bindtap="onTaskTap"
bindlongpress="onTaskLongPress"
>
<!-- 左侧彩色边条 -->
<view class="card-border" style="background-color: {{item.typeColor}};"></view>
<view class="card-body">
<!-- 第一行:标签 + 客户名 + 爱心 + 备注 -->
<view class="card-row-1">
<view class="task-type-tag" style="background-color: {{item.typeColor}};">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="pin-indicator" wx:if="{{item.isPinned}}">📌</text>
</view>
<!-- 第二行:截止时间 -->
<view class="card-row-2">
<text class="deadline-text">截止:{{item.deadline}}</text>
</view>
<!-- 第三行:喜好标签 -->
<view class="card-row-3" wx:if="{{item.hobbies.length > 0}}">
<hobby-tag
wx:for="{{item.hobbies}}"
wx:for-item="hobby"
wx:key="*this"
type="{{hobby}}"
/>
</view>
</view>
<!-- 右侧箭头 -->
<view class="card-arrow">
<text class="arrow-icon"></text>
</view>
</view>
<!-- 加载更多提示 -->
<view class="load-more" wx:if="{{!hasMore}}">
<text class="load-more-text">— 没有更多了 —</text>
</view>
</view>
<!-- ====== AI 悬浮按钮 ====== -->
<ai-float-button visible="{{pageState !== 'loading'}}" bottom="{{200}}" />
</view>
<dev-fab />

View File

@@ -0,0 +1,173 @@
/* 任务列表页样式 */
.page-task-list {
min-height: 100vh;
background-color: var(--color-gray-1);
padding-bottom: 180rpx; /* 为 AI 悬浮按钮 + 安全区留空 */
}
/* ====== Loading 骨架屏 ====== */
.state-loading {
padding: 32rpx;
}
.loading-placeholder {
background: #fff;
border-radius: var(--radius-lg);
padding: 32rpx;
margin-bottom: 24rpx;
}
.ph-line {
height: 24rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, var(--color-gray-2) 25%, var(--color-gray-1) 50%, var(--color-gray-2) 75%);
background-size: 400% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 20rpx;
}
.ph-line--title {
width: 40%;
height: 32rpx;
}
.ph-line--body {
width: 80%;
}
.ph-line--short {
width: 55%;
margin-bottom: 0;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ====== 空状态 ====== */
.state-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.empty-text {
font-size: var(--font-sm);
color: var(--color-gray-6);
}
/* ====== 任务区域 ====== */
.task-section {
padding: 32rpx;
}
/* 标题行 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--color-gray-13);
}
.section-count {
font-size: var(--font-sm);
color: var(--color-gray-6);
}
/* ====== 任务卡片 ====== */
.task-card {
display: flex;
align-items: stretch;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
margin-bottom: 24rpx;
overflow: hidden;
}
.task-card:active {
opacity: 0.85;
}
/* 左侧彩色边条 */
.card-border {
width: 8rpx;
flex-shrink: 0;
}
/* 卡片主体 */
.card-body {
flex: 1;
padding: 28rpx 24rpx;
min-width: 0; /* 防止溢出 */
}
/* 第一行 */
.card-row-1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 12rpx;
}
.task-type-tag {
padding: 4rpx 16rpx;
border-radius: var(--radius-sm);
}
.tag-text {
font-size: var(--font-xs);
color: #fff;
font-weight: 500;
}
.customer-name {
font-size: var(--font-base);
font-weight: 600;
color: var(--color-gray-13);
}
.note-indicator,
.pin-indicator {
font-size: var(--font-sm);
}
/* 第二行 */
.card-row-2 {
margin-bottom: 12rpx;
}
.deadline-text {
font-size: var(--font-sm);
color: var(--color-gray-7);
}
/* 第三行:喜好标签 */
.card-row-3 {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
/* 右侧箭头 */
.card-arrow {
display: flex;
align-items: center;
padding-right: 24rpx;
flex-shrink: 0;
}
.arrow-icon {
font-size: 40rpx;
color: var(--color-gray-5);
}
/* ====== 加载更多 ====== */
.load-more {
text-align: center;
padding: 32rpx 0;
}
.load-more-text {
font-size: var(--font-xs);
color: var(--color-gray-6);
}