feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations
This commit is contained in:
8
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.json
Normal file
8
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "申请访问权限",
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
146
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.ts
Normal file
146
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { request } from "../../utils/request"
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 0,
|
||||
siteCode: "",
|
||||
role: "",
|
||||
phone: "",
|
||||
employeeNumber: "",
|
||||
nickname: "",
|
||||
submitting: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const { statusBarHeight } = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this._checkAccess()
|
||||
},
|
||||
|
||||
/** 校验用户身份:无 token 跳登录,非 new/rejected 跳对应页 */
|
||||
async _checkAccess() {
|
||||
const token = wx.getStorageSync("token")
|
||||
if (!token) {
|
||||
wx.reLaunch({ url: "/pages/login/login" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await request({
|
||||
url: "/api/xcx/me",
|
||||
method: "GET",
|
||||
needAuth: true,
|
||||
})
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.authUser = {
|
||||
userId: data.user_id,
|
||||
status: data.status,
|
||||
nickname: data.nickname,
|
||||
}
|
||||
wx.setStorageSync("userId", data.user_id)
|
||||
wx.setStorageSync("userStatus", data.status)
|
||||
|
||||
switch (data.status) {
|
||||
case "new":
|
||||
break
|
||||
case "rejected":
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
break
|
||||
case "approved":
|
||||
wx.reLaunch({ url: "/pages/mvp/mvp" })
|
||||
break
|
||||
case "pending":
|
||||
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
|
||||
break
|
||||
case "disabled":
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// 网络错误不阻塞,允许用户继续填表
|
||||
}
|
||||
},
|
||||
|
||||
onBack() {
|
||||
wx.navigateBack({ fail: () => wx.reLaunch({ url: "/pages/login/login" }) })
|
||||
},
|
||||
|
||||
/* 原生 input 的 bindinput 事件 */
|
||||
onSiteCodeInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ siteCode: e.detail.value })
|
||||
},
|
||||
|
||||
onRoleInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ role: e.detail.value })
|
||||
},
|
||||
|
||||
onPhoneInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ phone: e.detail.value })
|
||||
},
|
||||
|
||||
onEmployeeNumberInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ employeeNumber: e.detail.value })
|
||||
},
|
||||
|
||||
onNicknameInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ nickname: e.detail.value })
|
||||
},
|
||||
|
||||
async onSubmit() {
|
||||
if (this.data.submitting) return
|
||||
|
||||
const { siteCode, role, phone, nickname, employeeNumber } = this.data
|
||||
|
||||
if (!siteCode.trim()) {
|
||||
wx.showToast({ title: "请输入球房ID", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (!role.trim()) {
|
||||
wx.showToast({ title: "请输入申请身份", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (!/^\d{11}$/.test(phone)) {
|
||||
wx.showToast({ title: "请输入11位手机号", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (!nickname.trim()) {
|
||||
wx.showToast({ title: "请输入昵称", icon: "none" })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ submitting: true })
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: "/api/xcx/apply",
|
||||
method: "POST",
|
||||
data: {
|
||||
site_code: siteCode.trim(),
|
||||
applied_role_text: role.trim(),
|
||||
phone,
|
||||
employee_number: employeeNumber.trim() || undefined,
|
||||
nickname: nickname.trim(),
|
||||
},
|
||||
needAuth: true,
|
||||
})
|
||||
|
||||
wx.showToast({ title: "申请已提交", icon: "success" })
|
||||
setTimeout(() => {
|
||||
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
|
||||
}, 800)
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.data?.detail ||
|
||||
(err?.statusCode === 409
|
||||
? "您已有待审核的申请"
|
||||
: err?.statusCode === 422
|
||||
? "表单信息有误,请检查"
|
||||
: "提交失败,请稍后重试")
|
||||
wx.showToast({ title: msg, icon: "none" })
|
||||
} finally {
|
||||
this.setData({ submitting: false })
|
||||
}
|
||||
},
|
||||
})
|
||||
107
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.wxml
Normal file
107
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.wxml
Normal 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 />
|
||||
271
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.wxss
Normal file
271
apps/DEMO-miniprogram/miniprogram/pages/apply/apply.wxss
Normal file
@@ -0,0 +1,271 @@
|
||||
/* pages/apply/apply.wxss — H5 px × 2 × 0.875 精确转换 */
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--primary-shadow-minimal) 0%, var(--color-primary-light) 50%, var(--primary-shadow-minimal) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- 顶部导航栏 h-11=44px→78rpx ---- */
|
||||
.navbar {
|
||||
height: 78rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-white-overlay-light);
|
||||
border-bottom: 1rpx solid var(--border-light);
|
||||
}
|
||||
|
||||
.navbar-back {
|
||||
position: absolute;
|
||||
left: 28rpx;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
/* text-base=16px→28rpx */
|
||||
.navbar-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
/* ---- 主体内容 p-4=16px→28rpx ---- */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 28rpx;
|
||||
padding-bottom: 380rpx;
|
||||
}
|
||||
|
||||
/* ---- 欢迎卡片 p-5=20px→36rpx, rounded-2xl=16px→28rpx ---- */
|
||||
.welcome-card {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
|
||||
border-radius: 28rpx;
|
||||
padding: 36rpx;
|
||||
margin-bottom: 28rpx;
|
||||
box-shadow: 0 14rpx 36rpx var(--color-primary-shadow-light);
|
||||
}
|
||||
|
||||
/* gap-4=16px→28rpx, mb-4=16px→28rpx */
|
||||
.welcome-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
/* w-12 h-12=48px→84rpx, rounded-xl=12px→22rpx */
|
||||
.welcome-icon-box {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
min-width: 84rpx;
|
||||
background: var(--color-white-overlay-lighter);
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
/* text-lg=18px→32rpx */
|
||||
.welcome-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
/* text-sm=14px→24rpx */
|
||||
.welcome-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-white-overlay-light);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ---- 审核流程步骤条 p-4=16px→28rpx, rounded-xl=12px→22rpx ---- */
|
||||
.steps-bar {
|
||||
background: var(--color-white-overlay-minimal);
|
||||
border-radius: 22rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.steps-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
/* w-7 h-7=28px→50rpx, text-xs=12px→22rpx */
|
||||
.step-circle {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--color-white-overlay-lighter);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
color: var(--color-white-overlay-light);
|
||||
}
|
||||
|
||||
.step-circle--active {
|
||||
background: var(--color-white);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* text-xs=12px→22rpx */
|
||||
.step-label {
|
||||
font-size: 18rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-label--active {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* h-0.5=2px→4rpx, mx-2=8px→14rpx */
|
||||
.step-line {
|
||||
flex: 1;
|
||||
height: 4rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
margin: 0 10rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* ---- 表单卡片 rounded-2xl=16px→28rpx ---- */
|
||||
.form-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* px-5=20px→36rpx, py-4=16px→28rpx+2rpx 视觉补偿 */
|
||||
.form-item {
|
||||
padding: 30rpx 36rpx;
|
||||
}
|
||||
|
||||
.form-item--border {
|
||||
border-bottom: 2rpx solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* text-sm=14px→24rpx, mb-2=8px→14rpx */
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
margin-bottom: 14rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* text-sm=14px→24rpx */
|
||||
.required {
|
||||
color: var(--color-error);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* text-xs=12px→22rpx */
|
||||
.optional-tag {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
/* px-4=16px→28rpx, py-3=12px→22rpx, rounded-xl=12px→22rpx, text-sm=14px→24rpx
|
||||
小程序 input 组件内部有压缩,py 加 4rpx 补偿到视觉等高 */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 28rpx;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 22rpx;
|
||||
border: 2rpx solid var(--bg-tertiary);
|
||||
font-size: 24rpx;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ---- 表单提示(移入底部固定区) ---- */
|
||||
.form-tip {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 18rpx;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ---- 底部提交 p-4=16px→28rpx, pb-8=32px→56rpx ---- */
|
||||
.bottom-area {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 28rpx;
|
||||
padding-bottom: calc(56rpx + env(safe-area-inset-bottom));
|
||||
background: var(--color-white-overlay-light);
|
||||
border-top: 2rpx solid var(--bg-tertiary);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* py-4=16px→28rpx (用padding代替固定高度), rounded-xl=12px→22rpx, text-base=16px→28rpx */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 28rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
|
||||
border-radius: 22rpx;
|
||||
box-shadow: 0 10rpx 28rpx var(--color-primary-shadow);
|
||||
}
|
||||
|
||||
.submit-btn--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* text-base=16px→28rpx */
|
||||
.submit-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
/* text-xs=12px→22rpx, mt-3=12px→22rpx */
|
||||
.bottom-tip {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 20rpx;
|
||||
color: var(--text-disabled);
|
||||
margin-top: 22rpx;
|
||||
font-weight: 300;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"navigationBarTitleText": "助教看板",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
|
||||
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"board-tab-bar": "/custom-tab-bar/index",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// 助教看板页 — 排序×技能×时间三重筛选,4 种维度卡片
|
||||
// TODO: 联调时替换 mock 数据为真实 API 调用
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
import { formatHours } from '../../utils/time'
|
||||
|
||||
export {}
|
||||
|
||||
/** 排序维度 → 卡片模板映射 */
|
||||
type DimType = 'perf' | 'salary' | 'sv' | 'task'
|
||||
|
||||
const SORT_TO_DIM: Record<string, DimType> = {
|
||||
perf_desc: 'perf',
|
||||
perf_asc: 'perf',
|
||||
salary_desc: 'salary',
|
||||
salary_asc: 'salary',
|
||||
sv_desc: 'sv',
|
||||
task_desc: 'task',
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'perf_desc', text: '定档业绩最高' },
|
||||
{ value: 'perf_asc', text: '定档业绩最低' },
|
||||
{ value: 'salary_desc', text: '工资最高' },
|
||||
{ value: 'salary_asc', text: '工资最低' },
|
||||
{ value: 'sv_desc', text: '客源储值最高' },
|
||||
{ value: 'task_desc', text: '任务完成最多' },
|
||||
]
|
||||
|
||||
const SKILL_OPTIONS = [
|
||||
{ value: 'all', text: '不限' },
|
||||
{ value: 'chinese', text: '🎱 中式/追分' },
|
||||
{ value: 'snooker', text: '斯诺克' },
|
||||
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
|
||||
{ value: 'karaoke', text: '🎤 团建/K歌' },
|
||||
]
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ value: 'month', text: '本月' },
|
||||
{ value: 'quarter', text: '本季度' },
|
||||
{ value: 'last_month', text: '上月' },
|
||||
{ value: 'last_3m', text: '前3个月(不含本月)' },
|
||||
{ value: 'last_quarter', text: '上季度' },
|
||||
{ value: 'last_6m', text: '最近6个月(不含本月,不支持客源储值最高)' },
|
||||
]
|
||||
|
||||
/** 等级 → 样式类映射 */
|
||||
const LEVEL_CLASS: Record<string, string> = {
|
||||
star: 'level--star',
|
||||
senior: 'level--high',
|
||||
middle: 'level--mid',
|
||||
junior: 'level--low',
|
||||
// 兼容旧中文 key 过渡期
|
||||
'星级': 'level--star',
|
||||
'高级': 'level--high',
|
||||
'中级': 'level--mid',
|
||||
'初级': 'level--low',
|
||||
}
|
||||
|
||||
/** 技能 → 样式类映射 */
|
||||
const SKILL_CLASS: Record<string, string> = {
|
||||
'🎱': 'skill--chinese',
|
||||
'斯': 'skill--snooker',
|
||||
'🀄': 'skill--mahjong',
|
||||
'🎤': 'skill--karaoke',
|
||||
}
|
||||
|
||||
interface CoachItem {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
avatarGradient: string
|
||||
level: string // 英文 key: star | senior | middle | junior
|
||||
levelClass: string
|
||||
skills: Array<{ text: string; cls: string }>
|
||||
topCustomers: string[]
|
||||
// 定档业绩维度
|
||||
perfHours: number
|
||||
perfHoursBefore?: number
|
||||
perfGap?: string
|
||||
perfReached: boolean
|
||||
// 工资维度
|
||||
salary: number
|
||||
salaryPerfHours: number
|
||||
salaryPerfBefore?: number
|
||||
// 客源储值维度
|
||||
svAmount: number
|
||||
svCustomerCount: number
|
||||
svConsume: number
|
||||
// 任务维度
|
||||
taskRecall: number
|
||||
taskCallback: number
|
||||
}
|
||||
|
||||
/** Mock 数据(忠于 H5 原型 6 位助教) */
|
||||
const MOCK_COACHES: CoachItem[] = [
|
||||
{
|
||||
id: 'c1', name: '小燕', initial: '小',
|
||||
avatarGradient: 'blue',
|
||||
level: 'star', levelClass: LEVEL_CLASS['star'],
|
||||
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
|
||||
topCustomers: ['💖 王先生', '💖 李女士', '💛 赵总'],
|
||||
perfHours: 86.2, perfHoursBefore: 92.0, perfGap: '距升档 13.8h', perfReached: false,
|
||||
salary: 12680, salaryPerfHours: 86.2, salaryPerfBefore: 92.0,
|
||||
svAmount: 45200, svCustomerCount: 18, svConsume: 8600,
|
||||
taskRecall: 18, taskCallback: 14,
|
||||
},
|
||||
{
|
||||
id: 'c2', name: '泡芙', initial: '泡',
|
||||
avatarGradient: 'green',
|
||||
level: 'senior', levelClass: LEVEL_CLASS['senior'],
|
||||
skills: [{ text: '斯', cls: SKILL_CLASS['斯'] }],
|
||||
topCustomers: ['💖 陈先生', '💛 刘女士', '💛 黄总'],
|
||||
perfHours: 72.5, perfHoursBefore: 78.0, perfGap: '距升档 7.5h', perfReached: false,
|
||||
salary: 10200, salaryPerfHours: 72.5, salaryPerfBefore: 78.0,
|
||||
svAmount: 38600, svCustomerCount: 15, svConsume: 6200,
|
||||
taskRecall: 15, taskCallback: 13,
|
||||
},
|
||||
{
|
||||
id: 'c3', name: 'Lucy', initial: 'A',
|
||||
avatarGradient: 'pink',
|
||||
level: 'star', levelClass: LEVEL_CLASS['star'],
|
||||
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
|
||||
topCustomers: ['💖 张先生', '💛 周女士', '💛 吴总'],
|
||||
perfHours: 68.0, perfHoursBefore: 72.5, perfGap: '距升档 32.0h', perfReached: false,
|
||||
salary: 9800, salaryPerfHours: 68.0, salaryPerfBefore: 72.5,
|
||||
svAmount: 32100, svCustomerCount: 14, svConsume: 5800,
|
||||
taskRecall: 12, taskCallback: 13,
|
||||
},
|
||||
{
|
||||
id: 'c4', name: 'Mia', initial: 'M',
|
||||
avatarGradient: 'amber',
|
||||
level: 'middle', levelClass: LEVEL_CLASS['middle'],
|
||||
skills: [{ text: '🀄', cls: SKILL_CLASS['🀄'] }],
|
||||
topCustomers: ['💛 赵先生', '💛 吴女士', '💛 孙总'],
|
||||
perfHours: 55.0, perfGap: '距升档 5.0h', perfReached: false,
|
||||
salary: 7500, salaryPerfHours: 55.0,
|
||||
svAmount: 28500, svCustomerCount: 12, svConsume: 4100,
|
||||
taskRecall: 10, taskCallback: 10,
|
||||
},
|
||||
{
|
||||
id: 'c5', name: '糖糖', initial: '糖',
|
||||
avatarGradient: 'violet',
|
||||
level: 'junior', levelClass: LEVEL_CLASS['junior'],
|
||||
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
|
||||
topCustomers: ['💛 钱先生', '💛 孙女士', '💛 周总'],
|
||||
perfHours: 42.0, perfHoursBefore: 45.0, perfReached: true,
|
||||
salary: 6200, salaryPerfHours: 42.0, salaryPerfBefore: 45.0,
|
||||
svAmount: 22000, svCustomerCount: 10, svConsume: 3500,
|
||||
taskRecall: 8, taskCallback: 10,
|
||||
},
|
||||
{
|
||||
id: 'c6', name: '露露', initial: '露',
|
||||
avatarGradient: 'cyan',
|
||||
level: 'middle', levelClass: LEVEL_CLASS['middle'],
|
||||
skills: [{ text: '🎤', cls: SKILL_CLASS['🎤'] }],
|
||||
topCustomers: ['💛 郑先生', '💛 冯女士', '💛 陈总'],
|
||||
perfHours: 38.0, perfGap: '距升档 22.0h', perfReached: false,
|
||||
salary: 5100, salaryPerfHours: 38.0,
|
||||
svAmount: 18300, svCustomerCount: 9, svConsume: 2800,
|
||||
taskRecall: 6, taskCallback: 9,
|
||||
},
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
activeTab: 'coach' as 'finance' | 'customer' | 'coach',
|
||||
|
||||
selectedSort: 'perf_desc',
|
||||
sortOptions: SORT_OPTIONS,
|
||||
selectedSkill: 'all',
|
||||
skillOptions: SKILL_OPTIONS,
|
||||
selectedTime: 'month',
|
||||
timeOptions: TIME_OPTIONS,
|
||||
|
||||
/** 当前维度类型,控制卡片模板 */
|
||||
dimType: 'perf' as DimType,
|
||||
|
||||
coaches: [] as CoachItem[],
|
||||
allCoaches: [] as CoachItem[],
|
||||
|
||||
/** 筛选栏可见性(滚动隐藏/显示) */
|
||||
filterBarVisible: true,
|
||||
},
|
||||
|
||||
_lastScrollTop: 0,
|
||||
_scrollAcc: 0,
|
||||
_scrollDir: null as 'up' | 'down' | null,
|
||||
|
||||
onLoad() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 500)
|
||||
},
|
||||
|
||||
/** 滚动隐藏/显示筛选栏 */
|
||||
onPageScroll(e: { scrollTop: number }) {
|
||||
const y = e.scrollTop
|
||||
const delta = y - this._lastScrollTop
|
||||
this._lastScrollTop = y
|
||||
|
||||
if (y <= 8) {
|
||||
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
|
||||
this._scrollAcc = 0
|
||||
this._scrollDir = null
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.abs(delta) <= 2) return
|
||||
const dir = delta > 0 ? 'down' : 'up'
|
||||
if (dir !== this._scrollDir) {
|
||||
this._scrollDir = dir
|
||||
this._scrollAcc = 0
|
||||
}
|
||||
this._scrollAcc += Math.abs(delta)
|
||||
|
||||
const threshold = dir === 'up' ? 12 : 24
|
||||
if (this._scrollAcc < threshold) return
|
||||
|
||||
const visible = dir === 'up'
|
||||
if (this.data.filterBarVisible !== visible) {
|
||||
this.setData({ filterBarVisible: visible })
|
||||
}
|
||||
this._scrollAcc = 0
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
const data = MOCK_COACHES
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
// 格式化数字字段为展示字符串
|
||||
const enriched = data.map((c) => ({
|
||||
...c,
|
||||
perfHoursLabel: formatHours(c.perfHours),
|
||||
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
|
||||
salaryLabel: formatMoney(c.salary),
|
||||
salaryPerfHoursLabel: formatHours(c.salaryPerfHours),
|
||||
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
|
||||
svAmountLabel: formatMoney(c.svAmount),
|
||||
svCustomerCountLabel: formatCount(c.svCustomerCount, '人'),
|
||||
svConsumeLabel: formatMoney(c.svConsume),
|
||||
taskRecallLabel: formatCount(c.taskRecall, '次'),
|
||||
taskCallbackLabel: formatCount(c.taskCallback, '次'),
|
||||
}))
|
||||
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
|
||||
}, 400)
|
||||
},
|
||||
|
||||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as string
|
||||
if (tab === 'finance') {
|
||||
wx.switchTab({ url: '/pages/board-finance/board-finance' })
|
||||
} else if (tab === 'customer') {
|
||||
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
|
||||
}
|
||||
},
|
||||
|
||||
onSortChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const val = e.detail.value
|
||||
this.setData({
|
||||
selectedSort: val,
|
||||
dimType: SORT_TO_DIM[val] || 'perf',
|
||||
})
|
||||
},
|
||||
|
||||
onSkillChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ selectedSkill: e.detail.value })
|
||||
},
|
||||
|
||||
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ selectedTime: e.detail.value })
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onCoachTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
wx.navigateTo({ url: '/pages/coach-detail/coach-detail?id=' + id })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
<!-- 助教看板页 — 忠于 H5 原型结构 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-empty description="暂无助教数据" />
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-empty description="加载失败" />
|
||||
<view class="retry-btn" bindtap="onRetry">点击重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:else>
|
||||
<!-- 顶部看板 Tab -->
|
||||
<view class="board-tabs">
|
||||
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
|
||||
<text>财务</text>
|
||||
</view>
|
||||
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
|
||||
<text>客户</text>
|
||||
</view>
|
||||
<view class="board-tab board-tab--active" data-tab="coach">
|
||||
<text>助教</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
|
||||
<view class="filter-bar-inner">
|
||||
<view class="filter-item filter-item--wide">
|
||||
<filter-dropdown label="定档业绩最高" options="{{sortOptions}}" value="{{selectedSort}}" bind:change="onSortChange" />
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<filter-dropdown label="不限" options="{{skillOptions}}" value="{{selectedSkill}}" bind:change="onSkillChange" />
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<filter-dropdown label="本月" options="{{timeOptions}}" value="{{selectedTime}}" bind:change="onTimeChange" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 助教列表 -->
|
||||
<view class="coach-list">
|
||||
<view class="coach-card" wx:for="{{coaches}}" wx:key="id" data-id="{{item.id}}" bindtap="onCoachTap" hover-class="coach-card--hover">
|
||||
<view class="card-row">
|
||||
<!-- 头像 -->
|
||||
<view class="card-avatar avatar-{{item.avatarGradient}}">
|
||||
<text class="avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<view class="card-info">
|
||||
<!-- 第一行:姓名 + 等级 + 技能 + 右侧指标 -->
|
||||
<view class="card-name-row">
|
||||
<text class="card-name">{{item.name}}</text>
|
||||
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
|
||||
<text class="skill-tag {{skill.cls}}" wx:for="{{item.skills}}" wx:for-item="skill" wx:key="text">{{skill.text}}</text>
|
||||
|
||||
<!-- 定档业绩维度 -->
|
||||
<view class="card-right" wx:if="{{dimType === 'perf'}}">
|
||||
<text class="right-text">定档 <text class="right-highlight">{{item.perfHoursLabel}}</text></text>
|
||||
<text class="right-sub" wx:if="{{item.perfHoursBeforeLabel}}">折前 <text class="right-sub-val">{{item.perfHoursBeforeLabel}}</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 工资维度 -->
|
||||
<view class="card-right" wx:elif="{{dimType === 'salary'}}">
|
||||
<text class="salary-tag">预估</text>
|
||||
<text class="salary-amount">{{item.salaryLabel}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 客源储值维度 -->
|
||||
<view class="card-right" wx:elif="{{dimType === 'sv'}}">
|
||||
<text class="right-sub">储值</text>
|
||||
<text class="salary-amount">{{item.svAmountLabel}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 任务维度 -->
|
||||
<view class="card-right" wx:elif="{{dimType === 'task'}}">
|
||||
<text class="right-text">召回 <text class="right-highlight">{{item.taskRecallLabel}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 第二行:客户列表 + 右侧补充 -->
|
||||
<view class="card-bottom-row">
|
||||
<view class="customer-list">
|
||||
<block wx:for="{{item.topCustomers}}" wx:for-item="cust" wx:for-index="custIdx" wx:key="*this">
|
||||
<text class="customer-item" wx:if="{{dimType !== 'sv' || custIdx < 2}}">{{cust}}</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 定档业绩:距升档/已达标 -->
|
||||
<text class="bottom-right bottom-right--warning" wx:if="{{dimType === 'perf' && !item.perfReached}}">{{item.perfGap}}</text>
|
||||
<text class="bottom-right bottom-right--success" wx:elif="{{dimType === 'perf' && item.perfReached}}">✅ 已达标</text>
|
||||
|
||||
<!-- 工资:定档/折前 -->
|
||||
<view class="bottom-right-group" wx:elif="{{dimType === 'salary'}}">
|
||||
<text class="bottom-perf">定档 <text class="bottom-perf-val">{{item.salaryPerfHoursLabel}}</text></text>
|
||||
<text class="bottom-sub" wx:if="{{item.salaryPerfBeforeLabel}}">折前 <text class="bottom-sub-val">{{item.salaryPerfBeforeLabel}}</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 客源储值:客户数 | 消耗 -->
|
||||
<view class="bottom-right-group" wx:elif="{{dimType === 'sv'}}">
|
||||
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{item.svCustomerCountLabel}}</text></text>
|
||||
<text class="bottom-divider">|</text>
|
||||
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{item.svConsumeLabel}}</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 任务:回访数 -->
|
||||
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{item.taskCallbackLabel}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区(为自定义导航栏留空间) -->
|
||||
<view class="safe-bottom"></view>
|
||||
</block>
|
||||
|
||||
<!-- 自定义底部导航栏 -->
|
||||
<board-tab-bar active="board" />
|
||||
|
||||
<!-- AI 悬浮按钮 — 在导航栏上方 -->
|
||||
<ai-float-button />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
@@ -0,0 +1,338 @@
|
||||
/* 助教看板页 — H5 px × 2 × 0.875 精确转换 */
|
||||
|
||||
/* ===== 三态 ===== */
|
||||
.page-loading,
|
||||
.page-empty,
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
font-size: 28rpx;
|
||||
color: #0052d9;
|
||||
border: 2rpx solid #0052d9;
|
||||
border-radius: 44rpx;
|
||||
}
|
||||
|
||||
/* ===== 看板 Tab py-3=12px→22rpx, text-sm=14px→26rpx(视觉校准+2) ===== */
|
||||
.board-tabs {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* text-sm=14px→24rpx, line-height 匹配 Tailwind 默认 1.25rem=20px→36rpx */
|
||||
.board-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 22rpx 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
font-weight: 500;
|
||||
color: #8b8b8b;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.board-tab--active {
|
||||
color: #0052d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* w-24px→42rpx, h-3px→5rpx(H5 实际渲染偏细) */
|
||||
.board-tab--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 42rpx;
|
||||
height: 5rpx;
|
||||
background: linear-gradient(90deg, #0052d9, #5b9cf8);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
/* px-4=16px→28rpx, py-2=8px→14rpx, sticky top-[44px]→77rpx */
|
||||
.filter-bar {
|
||||
background: #f3f3f3;
|
||||
padding: 14rpx 28rpx;
|
||||
position: sticky;
|
||||
top: 77rpx;
|
||||
z-index: 15;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
transition: transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
|
||||
.filter-bar--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-110%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* p-1.5=6px→10rpx, gap-2=8px→14rpx, rounded-lg=8px→14rpx */
|
||||
.filter-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 10rpx;
|
||||
border: 2rpx solid #eeeeee;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-item--wide {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
/* p-4=16px→28rpx, space-y-3=12px→22rpx — 四边一致 */
|
||||
.coach-list {
|
||||
padding: 13rpx 28rpx 28rpx 28rpx;
|
||||
}
|
||||
|
||||
/* p-4=16px→28rpx, rounded-2xl=16px→32rpx(border-radius: px×2,不乘0.875) */
|
||||
.coach-card {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.coach-card--hover {
|
||||
opacity: 0.96;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* gap-3=12px→20rpx, items-center 忠于 H5 原型 */
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* w-11 h-11=44px→78rpx, text-base=16px→28rpx — items-center 对齐,无需 margin-top */
|
||||
.card-avatar {
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 头像渐变色由 app.wxss 全局 .avatar-{key} 统一提供(VI §8) */
|
||||
|
||||
/* ===== 信息区 ===== */
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* gap-1.5=6px→10rpx */
|
||||
.card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* text-base=16px→28rpx, line-height: 1.5→42rpx(匹配 Tailwind 默认) */
|
||||
.card-name {
|
||||
font-size: 28rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 等级标签 px-1.5=6px→10rpx, py-0.5=2px→4rpx, text-xs=12px→22rpx ===== */
|
||||
.level-tag {
|
||||
padding: 4rpx 10rpx;
|
||||
font-size: 22rpx;
|
||||
border-radius: 8rpx;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level--star {
|
||||
background: linear-gradient(to right, #fbbf24, #f97316);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.level--high {
|
||||
background: linear-gradient(to right, #a78bfa, #8b5cf6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.level--mid {
|
||||
background: linear-gradient(to right, #60a5fa, #6366f1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.level--low {
|
||||
background: linear-gradient(to right, #9ca3af, #6b7280);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ===== 技能标签 ===== */
|
||||
.skill-tag {
|
||||
padding: 4rpx 10rpx;
|
||||
font-size: 22rpx;
|
||||
border-radius: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill--chinese { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
|
||||
.skill--snooker { background: rgba(0, 168, 112, 0.1); color: #00a870; }
|
||||
.skill--mahjong { background: rgba(237, 123, 47, 0.1); color: #ed7b2f; }
|
||||
.skill--karaoke { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
|
||||
|
||||
/* ml-auto, gap-2=8px→14rpx */
|
||||
.card-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* text-xs=12px→22rpx — "定档"标签文字 */
|
||||
.right-text {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* text-sm=14px→24rpx — 定档数值加粗 */
|
||||
.right-highlight {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
/* "折前"/"储值" 辅助文字 — text-xs=12px→22rpx, gray-6=#a6a6a6 */
|
||||
.right-sub {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.right-sub-val {
|
||||
color: #8b8b8b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 工资维度 */
|
||||
.salary-tag {
|
||||
padding: 4rpx 10rpx;
|
||||
background: rgba(237, 123, 47, 0.1);
|
||||
color: #ed7b2f;
|
||||
font-size: 22rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
/* text-lg=18px→32rpx, line-height: 28px→50rpx(匹配 Tailwind text-lg 的 1.75rem) */
|
||||
.salary-amount {
|
||||
font-size: 32rpx;
|
||||
line-height: 50rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
/* mt-1.5=6px→10rpx, text-xs=12px→22rpx */
|
||||
.card-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
/* gap-2=8px→14rpx */
|
||||
.customer-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-item {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-right--warning {
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.bottom-right--success {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
.bottom-right-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-perf {
|
||||
font-size: 22rpx;
|
||||
color: #4b4b4b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bottom-perf-val {
|
||||
font-weight: 700;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
.bottom-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
.bottom-sub-val {
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
.bottom-divider {
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
/* ===== 底部安全区(自定义导航栏高度 100rpx + safe-area) ===== */
|
||||
.safe-bottom {
|
||||
height: 200rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"navigationBarTitleText": "客户看板",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
|
||||
"heart-icon": "/components/heart-icon/heart-icon",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"board-tab-bar": "/custom-tab-bar/index",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// 客户看板页 — 8 个维度查看前 100 名客户
|
||||
// TODO: 联调时替换 mock 数据为真实 API 调用
|
||||
import { initPageAiColor } from '../../utils/ai-color-manager'
|
||||
|
||||
export {}
|
||||
|
||||
/** 维度类型 → 卡片模板映射 */
|
||||
type DimType = 'recall' | 'potential' | 'balance' | 'recharge' | 'recent' | 'spend60' | 'freq60' | 'loyal'
|
||||
|
||||
const DIMENSION_TO_DIM: Record<string, DimType> = {
|
||||
recall: 'recall',
|
||||
potential: 'potential',
|
||||
balance: 'balance',
|
||||
recharge: 'recharge',
|
||||
recent: 'recent',
|
||||
spend60: 'spend60',
|
||||
freq60: 'freq60',
|
||||
loyal: 'loyal',
|
||||
}
|
||||
|
||||
const DIMENSION_OPTIONS = [
|
||||
{ value: 'recall', text: '最应召回' },
|
||||
{ value: 'potential', text: '最大消费潜力' },
|
||||
{ value: 'balance', text: '最高余额' },
|
||||
{ value: 'recharge', text: '最近充值' },
|
||||
{ value: 'recent', text: '最近到店' },
|
||||
{ value: 'spend60', text: '最高消费 近60天' },
|
||||
{ value: 'freq60', text: '最频繁 近60天' },
|
||||
{ value: 'loyal', text: '最专一 近60天' },
|
||||
]
|
||||
|
||||
const PROJECT_OPTIONS = [
|
||||
{ value: 'all', text: '全部' },
|
||||
{ value: 'chinese', text: '🎱 中式/追分' },
|
||||
{ value: 'snooker', text: '斯诺克' },
|
||||
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
|
||||
{ value: 'karaoke', text: '🎤 团建/K歌' },
|
||||
]
|
||||
|
||||
interface AssistantInfo {
|
||||
name: string
|
||||
cls: string // assistant--assignee / assistant--abandoned / assistant--normal
|
||||
heartScore: number // 0-10,heart-icon 组件用
|
||||
badge?: string // 跟 / 弃
|
||||
badgeCls?: string // assistant-badge--follow / assistant-badge--drop
|
||||
}
|
||||
|
||||
interface CustomerItem {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
avatarCls: string
|
||||
// 召回维度
|
||||
idealDays: number
|
||||
elapsedDays: number
|
||||
overdueDays: number
|
||||
visits30d: number
|
||||
balance: string
|
||||
recallIndex: string
|
||||
// 消费潜力维度
|
||||
potentialTags: Array<{ text: string; theme: string }>
|
||||
spend30d: string
|
||||
avgVisits: string
|
||||
avgSpend: string
|
||||
// 余额维度
|
||||
lastVisit: string
|
||||
monthlyConsume: string
|
||||
availableMonths: string
|
||||
// 充值维度
|
||||
lastRecharge: string
|
||||
rechargeAmount: string
|
||||
recharges60d: string
|
||||
currentBalance: string
|
||||
// 消费60天
|
||||
spend60d: string
|
||||
visits60d: string
|
||||
highSpendTag: boolean
|
||||
// 频率60天
|
||||
avgInterval: string
|
||||
weeklyVisits: Array<{ val: number; pct: number }>
|
||||
// 专一度
|
||||
intimacy: string
|
||||
coachName: string
|
||||
coachRatio: string
|
||||
topCoachName: string
|
||||
topCoachHeart: number
|
||||
topCoachScore: string
|
||||
coachDetails: Array<{
|
||||
name: string
|
||||
cls: string
|
||||
heartScore: number
|
||||
badge?: string
|
||||
avgDuration: string
|
||||
serviceCount: string
|
||||
coachSpend: string
|
||||
relationIdx: number
|
||||
}>
|
||||
// 最近到店
|
||||
visitFreq: string
|
||||
daysAgo: number
|
||||
// 助教
|
||||
assistants: AssistantInfo[]
|
||||
}
|
||||
|
||||
/** Mock 数据(忠于 H5 原型 3 位客户) */
|
||||
const MOCK_CUSTOMERS: CustomerItem[] = [
|
||||
{
|
||||
id: 'u1', name: '王先生', initial: '王', avatarCls: 'avatar--amber',
|
||||
idealDays: 7, elapsedDays: 15, overdueDays: 8,
|
||||
visits30d: 3, balance: '¥2,680', recallIndex: '9.2',
|
||||
potentialTags: [
|
||||
{ text: '高频', theme: 'primary' },
|
||||
{ text: '高客单', theme: 'warning' },
|
||||
],
|
||||
spend30d: '¥4,200', avgVisits: '6.2次', avgSpend: '¥680',
|
||||
lastVisit: '3天前', monthlyConsume: '¥3,500', availableMonths: '约0.8个月',
|
||||
lastRecharge: '2月15日', rechargeAmount: '¥5,000', recharges60d: '2次', currentBalance: '¥2,680',
|
||||
spend60d: '¥8,400', visits60d: '12', highSpendTag: true,
|
||||
avgInterval: '5.0天', intimacy: '92',
|
||||
topCoachName: '小燕', topCoachHeart: 9.2, topCoachScore: '9.2',
|
||||
coachDetails: [
|
||||
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '跟', avgDuration: '2.3h', serviceCount: '14', coachSpend: '¥4,200', relationIdx: 9.2 },
|
||||
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5, avgDuration: '1.5h', serviceCount: '8', coachSpend: '¥2,100', relationIdx: 7.2 },
|
||||
],
|
||||
weeklyVisits: [
|
||||
{ val: 2, pct: 60 }, { val: 2, pct: 60 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
|
||||
{ val: 2, pct: 60 }, { val: 3, pct: 100 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
|
||||
], coachName: '小燕', coachRatio: '78%',
|
||||
visitFreq: '6.2次/月',
|
||||
daysAgo: 3,
|
||||
assistants: [
|
||||
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '跟', badgeCls: 'assistant-badge--follow' },
|
||||
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u2', name: '李女士', initial: '李', avatarCls: 'avatar--pink',
|
||||
idealDays: 10, elapsedDays: 22, overdueDays: 12,
|
||||
visits30d: 1, balance: '¥8,200', recallIndex: '8.5',
|
||||
potentialTags: [
|
||||
{ text: '高余额', theme: 'success' },
|
||||
{ text: '低频', theme: 'gray' },
|
||||
],
|
||||
spend30d: '¥1,800', avgVisits: '2.1次', avgSpend: '¥860',
|
||||
lastVisit: '12天前', monthlyConsume: '¥1,800', availableMonths: '约4.6个月',
|
||||
lastRecharge: '1月20日', rechargeAmount: '¥10,000', recharges60d: '1次', currentBalance: '¥8,200',
|
||||
spend60d: '¥3,600', visits60d: '4', highSpendTag: false,
|
||||
avgInterval: '15.0天', intimacy: '68',
|
||||
topCoachName: 'Lucy', topCoachHeart: 8.5, topCoachScore: '8.5',
|
||||
coachDetails: [
|
||||
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', avgDuration: '1.8h', serviceCount: '10', coachSpend: '¥3,600', relationIdx: 8.5 },
|
||||
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', avgDuration: '0.8h', serviceCount: '3', coachSpend: '¥600', relationIdx: 4.2 },
|
||||
],
|
||||
weeklyVisits: [
|
||||
{ val: 1, pct: 40 }, { val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 },
|
||||
{ val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 }, { val: 1, pct: 40 },
|
||||
], coachName: 'Lucy', coachRatio: '62%',
|
||||
visitFreq: '2.1次/月',
|
||||
daysAgo: 12,
|
||||
assistants: [
|
||||
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
|
||||
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', badgeCls: 'assistant-badge--drop' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'u3', name: '张先生', initial: '张', avatarCls: 'avatar--blue',
|
||||
idealDays: 5, elapsedDays: 8, overdueDays: 3,
|
||||
visits30d: 5, balance: '¥1,200', recallIndex: '7.8',
|
||||
potentialTags: [
|
||||
{ text: '高频', theme: 'primary' },
|
||||
],
|
||||
spend30d: '¥3,500', avgVisits: '8.0次', avgSpend: '¥440',
|
||||
lastVisit: '1天前', monthlyConsume: '¥3,200', availableMonths: '约0.4个月',
|
||||
lastRecharge: '3月1日', rechargeAmount: '¥3,000', recharges60d: '3次', currentBalance: '¥1,200',
|
||||
spend60d: '¥7,000', visits60d: '16', highSpendTag: true,
|
||||
avgInterval: '3.8天', intimacy: '95',
|
||||
topCoachName: '泡芙', topCoachHeart: 9.5, topCoachScore: '9.5',
|
||||
coachDetails: [
|
||||
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', avgDuration: '2.1h', serviceCount: '11', coachSpend: '¥3,300', relationIdx: 9.5 },
|
||||
{ name: '小燕', cls: 'assistant--normal', heartScore: 6.8, avgDuration: '1.3h', serviceCount: '6', coachSpend: '¥1,800', relationIdx: 6.8 },
|
||||
{ name: 'Lucy', cls: 'assistant--abandoned', heartScore: 3.2, badge: '弃', avgDuration: '0.5h', serviceCount: '2', coachSpend: '¥400', relationIdx: 3.2 },
|
||||
],
|
||||
weeklyVisits: [
|
||||
{ val: 2, pct: 50 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 4, pct: 100 },
|
||||
{ val: 3, pct: 75 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 3, pct: 75 },
|
||||
], coachName: '泡芙', coachRatio: '85%',
|
||||
visitFreq: '8.0次/月',
|
||||
daysAgo: 1,
|
||||
assistants: [
|
||||
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
activeTab: 'customer' as 'finance' | 'customer' | 'coach',
|
||||
|
||||
selectedDimension: 'recall',
|
||||
dimensionOptions: DIMENSION_OPTIONS,
|
||||
selectedProject: 'all',
|
||||
projectOptions: PROJECT_OPTIONS,
|
||||
|
||||
/** 当前维度类型,控制卡片模板 */
|
||||
dimType: 'recall' as DimType,
|
||||
|
||||
customers: [] as CustomerItem[],
|
||||
allCustomers: [] as CustomerItem[],
|
||||
totalCount: 0,
|
||||
|
||||
/** 筛选栏可见性 */
|
||||
filterBarVisible: true,
|
||||
},
|
||||
|
||||
_lastScrollTop: 0,
|
||||
_scrollAcc: 0,
|
||||
_scrollDir: null as 'up' | 'down' | null,
|
||||
|
||||
onLoad() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 500)
|
||||
},
|
||||
|
||||
/** 滚动隐藏/显示筛选栏 */
|
||||
onPageScroll(e: { scrollTop: number }) {
|
||||
const y = e.scrollTop
|
||||
const delta = y - this._lastScrollTop
|
||||
this._lastScrollTop = y
|
||||
|
||||
if (y <= 8) {
|
||||
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
|
||||
this._scrollAcc = 0
|
||||
this._scrollDir = null
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.abs(delta) <= 2) return
|
||||
const dir = delta > 0 ? 'down' : 'up'
|
||||
if (dir !== this._scrollDir) {
|
||||
this._scrollDir = dir
|
||||
this._scrollAcc = 0
|
||||
}
|
||||
this._scrollAcc += Math.abs(delta)
|
||||
|
||||
const threshold = dir === 'up' ? 12 : 24
|
||||
if (this._scrollAcc < threshold) return
|
||||
|
||||
const visible = dir === 'up'
|
||||
if (this.data.filterBarVisible !== visible) {
|
||||
this.setData({ filterBarVisible: visible })
|
||||
}
|
||||
this._scrollAcc = 0
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
const data = MOCK_CUSTOMERS
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
allCustomers: data,
|
||||
customers: data,
|
||||
totalCount: data.length,
|
||||
pageState: 'normal',
|
||||
})
|
||||
}, 400)
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as string
|
||||
if (tab === 'finance') {
|
||||
wx.switchTab({ url: '/pages/board-finance/board-finance' })
|
||||
} else if (tab === 'coach') {
|
||||
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
|
||||
}
|
||||
},
|
||||
|
||||
onDimensionChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const val = e.detail.value
|
||||
this.setData({
|
||||
selectedDimension: val,
|
||||
dimType: DIMENSION_TO_DIM[val] || 'recall',
|
||||
})
|
||||
},
|
||||
|
||||
onProjectChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ selectedProject: e.detail.value })
|
||||
},
|
||||
|
||||
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
wx.navigateTo({ url: '/pages/customer-detail/customer-detail?id=' + id })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
<!-- 客户看板页 — 忠于 H5 原型,8 维度卡片模板 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-empty description="暂无客户数据" />
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-empty description="加载失败" />
|
||||
<view class="retry-btn" bindtap="onRetry">点击重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:else>
|
||||
<!-- 顶部看板 Tab -->
|
||||
<view class="board-tabs">
|
||||
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
|
||||
<text>财务</text>
|
||||
</view>
|
||||
<view class="board-tab board-tab--active" data-tab="customer">
|
||||
<text>客户</text>
|
||||
</view>
|
||||
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
|
||||
<text>助教</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
|
||||
<view class="filter-bar-inner">
|
||||
<view class="filter-item filter-item--wide">
|
||||
<filter-dropdown
|
||||
label="最应召回"
|
||||
options="{{dimensionOptions}}"
|
||||
value="{{selectedDimension}}"
|
||||
bind:change="onDimensionChange"
|
||||
/>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<filter-dropdown
|
||||
label="全部"
|
||||
options="{{projectOptions}}"
|
||||
value="{{selectedProject}}"
|
||||
bind:change="onProjectChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表头部 -->
|
||||
<view class="list-header">
|
||||
<view class="list-header-left">
|
||||
<text class="list-header-title">客户列表</text>
|
||||
<text class="list-header-sub">· 前100名</text>
|
||||
</view>
|
||||
<text class="list-header-count">共{{totalCount}}名客户</text>
|
||||
</view>
|
||||
|
||||
<!-- 客户列表 -->
|
||||
<view class="customer-list">
|
||||
<view
|
||||
class="customer-card"
|
||||
hover-class="customer-card--hover"
|
||||
wx:for="{{customers}}"
|
||||
wx:key="id"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onCustomerTap"
|
||||
>
|
||||
<!-- ===== 卡片头部:头像 + 姓名 + 右侧指标 ===== -->
|
||||
<view class="card-header {{dimType === 'recall' || dimType === 'freq60' || dimType === 'recent' ? '' : 'card-header--lg'}}">
|
||||
<view class="card-avatar {{item.avatarCls}}">
|
||||
<text class="avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
<text class="card-name" wx:if="{{dimType !== 'freq60'}}">{{item.name}}</text>
|
||||
<!-- 最频繁:姓名 + 下方小字垂直排列 -->
|
||||
<view class="card-name-group" wx:if="{{dimType === 'freq60'}}">
|
||||
<text class="card-name">{{item.name}}</text>
|
||||
<view class="card-name-sub">
|
||||
<text class="mid-text" style="white-space: nowrap;">平均间隔 <text class="mid-primary">{{item.avgInterval}}</text></text>
|
||||
<text class="mid-text" style="white-space: nowrap;">60天消费 <text class="mid-dark">{{item.spend60d}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-header-spacer"></view>
|
||||
|
||||
<!-- 最应召回:理想/已过/超期 -->
|
||||
<view class="header-metrics" wx:if="{{dimType === 'recall'}}">
|
||||
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
|
||||
<text class="metric-gray">已过 <text class="metric-error">{{item.elapsedDays}}天</text></text>
|
||||
<view class="overdue-tag {{item.overdueDays > 7 ? 'overdue-tag--danger' : 'overdue-tag--warn'}}">
|
||||
<text>超期 {{item.overdueDays}}天</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最大消费潜力:频率/客单/余额标签 -->
|
||||
<view class="header-metrics" wx:elif="{{dimType === 'potential'}}">
|
||||
<view class="potential-tag potential-tag--{{tag.theme}}" wx:for="{{item.potentialTags}}" wx:for-item="tag" wx:key="text">
|
||||
<text>{{tag.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最高余额:最近到店/理想 -->
|
||||
<view class="header-metrics" wx:elif="{{dimType === 'balance'}}">
|
||||
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
|
||||
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 最近充值:最近到店/理想 -->
|
||||
<view class="header-metrics" wx:elif="{{dimType === 'recharge'}}">
|
||||
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
|
||||
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 最频繁近60天:右上角大字到店次数 -->
|
||||
<view class="header-metrics header-metrics--freq" wx:elif="{{dimType === 'freq60'}}">
|
||||
<view class="freq-big-num">
|
||||
<text class="freq-big-val">{{item.visits60d}}</text>
|
||||
<text class="freq-big-unit">次</text>
|
||||
</view>
|
||||
<text class="freq-big-label">60天到店</text>
|
||||
</view>
|
||||
|
||||
<!-- 最专一近60天:右上角 ❤️ 助教名 + 关系指数 -->
|
||||
<view class="header-metrics header-metrics--loyal" wx:elif="{{dimType === 'loyal'}}">
|
||||
<heart-icon score="{{item.topCoachHeart}}" />
|
||||
<text class="loyal-top-name">{{item.topCoachName}}</text>
|
||||
<text class="loyal-top-score">{{item.topCoachScore}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 最高消费近60天:高消费标签 -->
|
||||
<view class="header-metrics" wx:elif="{{dimType === 'spend60'}}">
|
||||
<view class="potential-tag potential-tag--warning" wx:if="{{item.highSpendTag}}">
|
||||
<text>高消费</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近到店:右上角大字 X天前到店 -->
|
||||
<view class="header-metrics header-metrics--recent" wx:elif="{{dimType === 'recent'}}">
|
||||
<view class="recent-big-num">
|
||||
<text class="recent-big-val">{{item.daysAgo}}</text>
|
||||
<text class="recent-big-unit">天前到店</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 卡片中间行:维度特定数据 ===== -->
|
||||
|
||||
<!-- 最应召回:30天到店 / 余额 / 召回指数 -->
|
||||
<view class="card-mid-row" wx:if="{{dimType === 'recall'}}">
|
||||
<text class="mid-text">30天到店 <text class="mid-dark">{{item.visits30d}}次</text></text>
|
||||
<text class="mid-text mid-ml">余额 <text class="mid-dark">{{item.balance}}</text></text>
|
||||
<text class="mid-text mid-right">召回指数 <text class="mid-primary-bold">{{item.recallIndex}}</text></text>
|
||||
</view>
|
||||
|
||||
<!-- 最大消费潜力:4 列网格(30天消费用橙色大字,和最高余额的余额值一致) -->
|
||||
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'potential'}}">
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">30天消费</text>
|
||||
<text class="grid-val grid-val--lg">{{item.spend30d}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">月均到店</text>
|
||||
<text class="grid-val">{{item.avgVisits}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">余额</text>
|
||||
<text class="grid-val grid-val--success">{{item.balance}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">次均消费</text>
|
||||
<text class="grid-val">{{item.avgSpend}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最高余额:3 列网格 -->
|
||||
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'balance'}}">
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">余额</text>
|
||||
<text class="grid-val grid-val--warning grid-val--lg">{{item.balance}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">月均消耗</text>
|
||||
<text class="grid-val">{{item.monthlyConsume}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">可用</text>
|
||||
<text class="grid-val grid-val--success">{{item.availableMonths}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近充值:4 列网格 -->
|
||||
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'recharge'}}">
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">最后充值</text>
|
||||
<text class="grid-val">{{item.lastRecharge}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">充值</text>
|
||||
<text class="grid-val grid-val--success">{{item.rechargeAmount}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">60天充值</text>
|
||||
<text class="grid-val">{{item.recharges60d}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">当前余额</text>
|
||||
<text class="grid-val grid-val--warning">{{item.currentBalance}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最高消费近60天:3 列网格 -->
|
||||
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'spend60'}}">
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">近60天消费</text>
|
||||
<text class="grid-val grid-val--warning grid-val--lg">{{item.spend60d}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">到店次数</text>
|
||||
<text class="grid-val">{{item.visits60d}}</text>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<text class="grid-label">次均消费</text>
|
||||
<text class="grid-val">{{item.avgSpend}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最频繁近60天:无中间行(数据已在头部) -->
|
||||
|
||||
<!-- 最频繁:迷你柱状图(8 周) -->
|
||||
<view class="mini-chart" wx:if="{{dimType === 'freq60'}}">
|
||||
<view class="mini-chart-header">
|
||||
<text class="mini-chart-label">8周前</text>
|
||||
<text class="mini-chart-label">本周</text>
|
||||
</view>
|
||||
<view class="mini-chart-bars">
|
||||
<view class="mini-bar-col" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">
|
||||
<view class="mini-bar" style="height:{{wv.pct}}%;opacity:{{0.2 + wIdx * 0.057}}"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mini-chart-nums">
|
||||
<text class="mini-chart-num {{wIdx === item.weeklyVisits.length - 1 ? 'mini-chart-num--active' : ''}}" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">{{wv.val}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最专一近60天:助教服务明细表 -->
|
||||
<view class="loyal-table" wx:elif="{{dimType === 'loyal'}}">
|
||||
<!-- 表头 -->
|
||||
<view class="loyal-row loyal-row--header">
|
||||
<text class="loyal-col loyal-col--name">助教</text>
|
||||
<text class="loyal-col">次均时长</text>
|
||||
<text class="loyal-col">服务次数</text>
|
||||
<text class="loyal-col">助教消费</text>
|
||||
<text class="loyal-col">关系指数</text>
|
||||
</view>
|
||||
<!-- 数据行 -->
|
||||
<view class="loyal-row" wx:for="{{item.coachDetails}}" wx:for-item="cd" wx:key="name">
|
||||
<view class="loyal-col loyal-col--name">
|
||||
<heart-icon score="{{cd.heartScore}}" />
|
||||
<text class="loyal-coach-name {{cd.cls}}">{{cd.name}}</text>
|
||||
<text class="assistant-badge assistant-badge--follow" wx:if="{{cd.badge === '跟'}}">跟</text>
|
||||
<text class="assistant-badge assistant-badge--drop" wx:elif="{{cd.badge === '弃'}}">弃</text>
|
||||
</view>
|
||||
<text class="loyal-col loyal-val">{{cd.avgDuration}}</text>
|
||||
<text class="loyal-col loyal-val">{{cd.serviceCount}}</text>
|
||||
<text class="loyal-col loyal-val">{{cd.coachSpend}}</text>
|
||||
<text class="loyal-col {{cd.relationIdx >= 8 ? 'loyal-val--primary' : 'loyal-val--gray'}}">{{cd.relationIdx}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近到店:理想间隔 / 60天到店 / 次均消费 -->
|
||||
<view class="card-mid-row" wx:elif="{{dimType === 'recent'}}">
|
||||
<text class="mid-text">理想间隔 <text class="mid-dark">{{item.idealDays}}天</text></text>
|
||||
<text class="mid-text mid-ml">60天到店 <text class="mid-dark">{{item.visits60d}}天</text></text>
|
||||
<text class="mid-text mid-ml-right">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
|
||||
</view>
|
||||
|
||||
<!-- ===== 卡片底部:助教行(最专一维度不显示,因为助教信息在表格中) ===== -->
|
||||
<view class="card-assistant-row" wx:if="{{item.assistants && item.assistants.length > 0 && dimType !== 'loyal'}}">
|
||||
<text class="assistant-label">助教:</text>
|
||||
<block wx:for="{{item.assistants}}" wx:for-item="ast" wx:for-index="astIdx" wx:key="name">
|
||||
<text class="assistant-sep" wx:if="{{astIdx > 0}}">|</text>
|
||||
<view class="assistant-tag">
|
||||
<heart-icon score="{{ast.heartScore}}" />
|
||||
<text class="assistant-name {{ast.cls}}">{{ast.name}}</text>
|
||||
<text class="assistant-badge assistant-badge--follow" wx:if="{{ast.badge === '跟'}}">跟</text>
|
||||
<text class="assistant-badge assistant-badge--drop" wx:elif="{{ast.badge === '弃'}}">弃</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区 -->
|
||||
<view class="safe-bottom"></view>
|
||||
</block>
|
||||
|
||||
<!-- 自定义底部导航栏 -->
|
||||
<board-tab-bar active="board" />
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
@@ -0,0 +1,692 @@
|
||||
/* 客户看板页 — 忠于 H5 原型,87.5% 缩放 */
|
||||
|
||||
/* ===== 三态 ===== */
|
||||
.page-loading,
|
||||
.page-empty,
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 51rpx; /* 修复:text-lg line-height 28px → 51rpx */
|
||||
color: #0052d9;
|
||||
border: 2rpx solid #0052d9;
|
||||
border-radius: 44rpx;
|
||||
}
|
||||
|
||||
/* ===== 看板 Tab(对齐 board-coach 规范) ===== */
|
||||
.board-tabs {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.board-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 22rpx 0;
|
||||
font-size: 26rpx; /* P1-6: 修正 14px → 26rpx */
|
||||
line-height: 36rpx;
|
||||
font-weight: 500;
|
||||
color: #8b8b8b;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.board-tab--active {
|
||||
color: #0052d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.board-tab--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 44rpx; /* 补充修正:24px → 44rpx */
|
||||
height: 6rpx; /* 补充修正:3px → 6rpx */
|
||||
background: linear-gradient(90deg, #0052d9, #5b9cf8);
|
||||
border-radius: 4rpx; /* 补充修正:2px → 4rpx */
|
||||
}
|
||||
|
||||
/* ===== 筛选区域(对齐 board-coach 规范) ===== */
|
||||
.filter-bar {
|
||||
background: #f3f3f3;
|
||||
padding: 14rpx 30rpx; /* P0-3: 修正 16px → 30rpx */
|
||||
position: sticky;
|
||||
top: 70rpx;
|
||||
z-index: 15;
|
||||
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
|
||||
transition: transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
|
||||
.filter-bar--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-110%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 30rpx; /* P0-2: 修正 16px → 30rpx */
|
||||
padding: 12rpx; /* 修正 6px → 12rpx */
|
||||
border: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-item--wide {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== 列表头部 ===== */
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx 36rpx 14rpx; /* P0-1: 修正 */
|
||||
}
|
||||
|
||||
.list-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx; /* P1-11: 修正 8px → 14rpx */
|
||||
}
|
||||
|
||||
.list-header-title {
|
||||
font-size: 30rpx; /* P1-7: 修正 16px → 30rpx */
|
||||
line-height: 44rpx; /* 修正:text-base line-height 24px → 44rpx */
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.list-header-sub {
|
||||
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
|
||||
line-height: 36rpx; /* 新增 */
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.list-header-count {
|
||||
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
|
||||
line-height: 36rpx; /* 新增 */
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
/* ===== 客户列表 ===== */
|
||||
.customer-list {
|
||||
padding: 0 30rpx 30rpx; /* P0-4: 修正 */
|
||||
margin-top: 8rpx; /* 修正 4px → 8rpx */
|
||||
}
|
||||
|
||||
/* ===== 客户卡片 ===== */
|
||||
.customer-card {
|
||||
background: #ffffff;
|
||||
border-radius: 30rpx; /* P2-15: 修正 16px → 30rpx */
|
||||
padding: 29rpx; /* 精确修正:16px → 29rpx(而非 30rpx)*/
|
||||
margin-bottom: 25rpx; /* 补充修正:12px → 22rpx */
|
||||
box-shadow: 0 3rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.customer-card--hover {
|
||||
opacity: 0.96;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ===== 卡片头部 ===== */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
/* 网格/表格维度头部间距更大 */
|
||||
.card-header--lg {
|
||||
margin-bottom: 2rpx; /* H5 mb-3=12px → 22rpx */
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 66rpx; /* 补充修正:36px → 66rpx */
|
||||
height: 66rpx; /* 补充修正:36px → 66rpx */
|
||||
border-radius: 22rpx; /* 补充修正:12px → 22rpx */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
line-height: 29rpx; /* 修复:text-xs line-height 16px → 29rpx(原为36rpx,差异17.6%)*/
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 头像渐变色 */
|
||||
.avatar--amber { background: linear-gradient(135deg, #fbbf24, #f97316); }
|
||||
.avatar--pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
|
||||
.avatar--blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
|
||||
.avatar--green { background: linear-gradient(135deg, #4ade80, #10b981); }
|
||||
.avatar--purple { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
|
||||
.avatar--cyan { background: linear-gradient(135deg, #22d3ee, #14b8a6); }
|
||||
.avatar--rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
|
||||
.avatar--indigo { background: linear-gradient(135deg, #818cf8, #4f46e5); }
|
||||
|
||||
.card-name {
|
||||
font-size: 32rpx; /* P1-9: 修正 16px → 30rpx */
|
||||
line-height: 44rpx; /* 高度修正:添加 line-height */
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 最频繁:姓名+小字垂直排列 */
|
||||
.card-name-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-name-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx; /* H5 gap-3=12px → 12×2×0.875≈22rpx */
|
||||
margin-top: 2rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 头部右侧指标区 */
|
||||
.header-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx; /* P1-12: 修正 8px → 14rpx */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 最频繁维度:右上角大字到店次数 */
|
||||
.header-metrics--freq {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.freq-big-num {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.freq-big-val {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #0052d9;
|
||||
line-height: 1; /* 补充:紧凑行高 */
|
||||
}
|
||||
|
||||
.freq-big-unit {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 修复:text-xs line-height 16px → 29rpx */
|
||||
font-weight: 400;
|
||||
color: #a6a6a6;
|
||||
margin-left: 2rpx;
|
||||
}
|
||||
|
||||
.freq-big-label {
|
||||
font-size: 18rpx; /* H5 text-[10px]=10px → 18rpx */
|
||||
line-height: 33rpx; /* 补充:约 1.8 倍 */
|
||||
color: #a6a6a6;
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
|
||||
/* 最近到店维度:右上角大字 X天前到店 */
|
||||
.header-metrics--recent {
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.recent-big-num {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.recent-big-val {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #00a870;
|
||||
line-height: 1; /* 补充:紧凑行高 */
|
||||
}
|
||||
|
||||
.recent-big-unit {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 修复:text-xs line-height 16px → 29rpx */
|
||||
font-weight: 400;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.metric-gray {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 新增 */
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.metric-dark {
|
||||
color: #393939;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-error {
|
||||
color: #e34d59;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 超期标签 */
|
||||
.overdue-tag {
|
||||
padding: 4rpx 12rpx; /* 补充修正:6px → 12rpx */
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 补充:text-xs line-height */
|
||||
font-weight: 700;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.overdue-tag--danger {
|
||||
background: rgba(227, 77, 89, 0.1);
|
||||
color: #e34d59;
|
||||
}
|
||||
|
||||
.overdue-tag--warn {
|
||||
background: rgba(237, 123, 47, 0.1);
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
/* 消费潜力标签 */
|
||||
.potential-tag {
|
||||
padding: 4rpx 12rpx; /* 补充修正:6px → 12rpx */
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 补充:text-xs line-height */
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.potential-tag--primary {
|
||||
background: rgba(0, 82, 217, 0.1);
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.potential-tag--warning {
|
||||
background: rgba(237, 123, 47, 0.1);
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.potential-tag--success {
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
.potential-tag--gray {
|
||||
background: #eeeeee;
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
/* ===== 卡片中间行(flex 布局,左对齐名字位置) ===== */
|
||||
.card-mid-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 80rpx; /* 只保留左侧对齐 */
|
||||
margin-bottom: 24rpx; /* 添加 mb-2 = 8px → 14rpx */
|
||||
}
|
||||
|
||||
.mid-text {
|
||||
font-size: 26rpx; /* P1-10: 修正 14px → 26rpx */
|
||||
line-height: 36rpx; /* 新增 */
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.mid-dark {
|
||||
color: #393939;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mid-primary {
|
||||
color: #0052d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mid-primary-bold {
|
||||
color: #0052d9;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mid-ml {
|
||||
margin-left: 30rpx; /* P0-5: 修正 16px → 30rpx */
|
||||
}
|
||||
|
||||
.mid-ml-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
.mid-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mid-error {
|
||||
color: #e34d59;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== 网格布局 ===== */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 14rpx; /* 补充修正:8px → 14rpx */
|
||||
padding-left: 80rpx; /* 移除上下 padding,只保留左侧对齐 */
|
||||
}
|
||||
|
||||
.card-grid--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
text-align: center;
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.card-grid--4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding-bottom: 22rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.grid-label {
|
||||
font-size: 18rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.grid-val {
|
||||
font-size: 26rpx; /* 修正:text-sm 14px → 26rpx */
|
||||
line-height: 36rpx; /* text-sm line-height */
|
||||
font-weight: 700;
|
||||
color: #393939;
|
||||
}
|
||||
|
||||
.grid-val--success {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
.grid-val--warning {
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.grid-val--lg {
|
||||
font-size: 30rpx; /* 修正:text-base 16px → 30rpx */
|
||||
line-height: 44rpx; /* 补充:text-base line-height */
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
/* ===== 迷你柱状图(最频繁维度) ===== */
|
||||
.mini-chart {
|
||||
padding: 0rpx 0 20rpx 80rpx; /* H5 mb-2=8px → 8×2×0.875≈14rpx */
|
||||
}
|
||||
|
||||
.mini-chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.mini-chart-label {
|
||||
font-size: 18rpx;
|
||||
line-height: 33rpx; /* 新增 */
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
.mini-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
|
||||
height: 42rpx; /* H5 h-6=24px → 42rpx */
|
||||
}
|
||||
|
||||
.mini-bar-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mini-bar {
|
||||
width: 100%;
|
||||
background: #0052d9;
|
||||
border-radius: 8rpx 8rpx 0 0; /* 补充修正:4px → 8rpx */
|
||||
min-height: 4rpx;
|
||||
}
|
||||
|
||||
.mini-chart-nums {
|
||||
display: flex;
|
||||
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.mini-chart-num {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 16rpx; /* H5 text-[9px]=9px → 16rpx */
|
||||
line-height: 29rpx; /* 新增 */
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
.mini-chart-num--active {
|
||||
color: #0052d9;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== 助教行 ===== */
|
||||
.card-assistant-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-left: 80rpx;
|
||||
padding-top: 2rpx; /* 精确修正:8px → 15rpx(而非 14rpx)*/
|
||||
border-top: 1px solid #f3f3f3; /* P2-13: 发丝线用 px */
|
||||
}
|
||||
|
||||
.assistant-label {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 新增 */
|
||||
color: #a6a6a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assistant-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.assistant-heart {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
.assistant-name {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx; /* 新增 */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assistant-name.assistant--assignee {
|
||||
color: #e34d59;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-name.assistant--abandoned {
|
||||
color: #c5c5c5; /* H5 assistant-abandoned = gray-5 #c5c5c5 */
|
||||
}
|
||||
|
||||
.assistant-name.assistant--normal {
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.assistant-sep {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx; /* 修复:text-xs line-height 16px → 29rpx */
|
||||
color: #c5c5c5;
|
||||
margin: 0 12rpx; /* 补充修正:6px → 12rpx */
|
||||
}
|
||||
|
||||
/* 跟/弃 badge — 忠于 H5 原型:白字 + 渐变背景 + 阴影 */
|
||||
.assistant-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28rpx;
|
||||
height: 24rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 10rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5rpx;
|
||||
margin-left: 4rpx;
|
||||
color: #ffffff;
|
||||
transform: translateY(3rpx); /* H5 原型:translateY(1.5px) → 3rpx */
|
||||
}
|
||||
|
||||
.assistant-badge--follow {
|
||||
background: linear-gradient(135deg, #e34d59 0%, #f26a76 100%);
|
||||
border: 1px solid rgba(227, 77, 89, 0.28); /* 补充修正:发丝线用 px */
|
||||
box-shadow: 0 2rpx 6rpx rgba(227, 77, 89, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.42); /* 补充修正:添加内阴影 */
|
||||
}
|
||||
|
||||
.assistant-badge--drop {
|
||||
background: linear-gradient(135deg, #d4d4d4 0%, #b6b6b6 100%);
|
||||
border: 1px solid rgba(120, 120, 120, 0.18); /* 补充修正:发丝线用 px */
|
||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.45); /* 补充修正:添加内阴影 */
|
||||
}
|
||||
|
||||
/* ===== 最专一维度:助教服务明细表 ===== */
|
||||
.loyal-table {
|
||||
border-left: 6rpx solid #d6d6d6;
|
||||
padding-left: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
margin-left: 22rpx;
|
||||
}
|
||||
|
||||
.loyal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
|
||||
padding: 0; /* 行间距由 space-y 控制 */
|
||||
}
|
||||
|
||||
.loyal-row + .loyal-row {
|
||||
margin-top: 2rpx; /* H5 space-y-2=8px → 14rpx */
|
||||
}
|
||||
|
||||
.loyal-row--header {
|
||||
padding-bottom: 10rpx; /* 间距由 margin-top 控制 */
|
||||
}
|
||||
|
||||
.loyal-row--header .loyal-col {
|
||||
font-size: 22rpx; /* H5 text-xs=12px → 22rpx */
|
||||
line-height: 29rpx; /* 补充:text-xs line-height */
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
.loyal-col {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx; /* 补充:text-sm 14px → 26rpx */
|
||||
line-height: 36rpx; /* 补充:text-sm line-height */
|
||||
}
|
||||
|
||||
.loyal-col--name {
|
||||
width: 168rpx;
|
||||
flex: none;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
|
||||
}
|
||||
|
||||
.loyal-coach-name {
|
||||
font-size: 24rpx; /* H5 text-sm=14px → 24rpx(数据行继承) */
|
||||
line-height: 36rpx; /* 新增 */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loyal-coach-name.assistant--assignee {
|
||||
color: #e34d59;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loyal-coach-name.assistant--abandoned {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.loyal-coach-name.assistant--normal {
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.loyal-val {
|
||||
font-size: 26rpx; /* 补充:继承 text-sm */
|
||||
line-height: 36rpx; /* 已有 */
|
||||
font-weight: 500; /* H5 font-medium=500 */
|
||||
color: #393939;
|
||||
}
|
||||
|
||||
.loyal-val--primary {
|
||||
font-weight: 700;
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.loyal-val--gray {
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
/* 最专一头部右侧 */
|
||||
.header-metrics--loyal {
|
||||
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
|
||||
}
|
||||
|
||||
.loyal-top-name {
|
||||
font-size: 26rpx; /* 补充:text-sm 14px → 26rpx */
|
||||
line-height: 36rpx; /* 补充:text-sm line-height */
|
||||
font-weight: 600;
|
||||
color: #e34d59;
|
||||
}
|
||||
|
||||
.loyal-top-score {
|
||||
font-size: 26rpx; /* 补充:text-sm 14px → 26rpx */
|
||||
line-height: 36rpx; /* 补充:text-sm line-height */
|
||||
font-weight: 700;
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
/* ===== 底部安全区 ===== */
|
||||
.safe-bottom {
|
||||
height: 200rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationBarTitleText": "看板",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"metric-card": "/components/metric-card/metric-card",
|
||||
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// 财务看板页 — 忠于 H5 原型结构,内联 mock 数据
|
||||
// TODO: 联调时替换 mock 数据为真实 API 调用
|
||||
|
||||
import { getRandomAiColor } from '../../utils/ai-color'
|
||||
|
||||
/** 目录板块定义 */
|
||||
interface TocItem {
|
||||
emoji: string
|
||||
title: string
|
||||
sectionId: string
|
||||
}
|
||||
|
||||
/** 指标解释映射 */
|
||||
const tipContents: Record<string, { title: string; content: string }> = {
|
||||
occurrence: {
|
||||
title: '发生额/正价',
|
||||
content: '所有消费项目按标价计算的总金额,不扣除任何优惠。\n\n即"如果没有任何折扣,客户应付多少"。',
|
||||
},
|
||||
discount: {
|
||||
title: '总优惠',
|
||||
content: '包含会员折扣、赠送卡抵扣、团购差价等所有优惠金额。\n\n优惠越高,实际收入越低。',
|
||||
},
|
||||
confirmed: {
|
||||
title: '成交/确认收入',
|
||||
content: '发生额减去总优惠后的实际入账金额。\n\n成交收入 = 发生额 - 总优惠',
|
||||
},
|
||||
cashIn: {
|
||||
title: '实收/现金流入',
|
||||
content: '实际到账的现金,包含消费直接支付和储值充值。\n\n往期为已结算金额,本期为截至当前的发生额。',
|
||||
},
|
||||
cashOut: {
|
||||
title: '现金支出',
|
||||
content: '包含人工、房租、水电、进货等所有经营支出。',
|
||||
},
|
||||
balance: {
|
||||
title: '现金结余',
|
||||
content: '现金流入减去现金支出。\n\n现金结余 = 现金流入 - 现金支出',
|
||||
},
|
||||
rechargeActual: {
|
||||
title: '储值卡充值实收',
|
||||
content: '会员储值卡首充和续费的实际到账金额。\n\n不含赠送金额。',
|
||||
},
|
||||
firstCharge: {
|
||||
title: '首充',
|
||||
content: '新会员首次充值的金额。',
|
||||
},
|
||||
renewCharge: {
|
||||
title: '续费',
|
||||
content: '老会员续费充值的金额。',
|
||||
},
|
||||
consume: {
|
||||
title: '消耗',
|
||||
content: '会员使用储值卡消费的金额。',
|
||||
},
|
||||
cardBalance: {
|
||||
title: '储值卡总余额',
|
||||
content: '所有储值卡的剩余可用余额。',
|
||||
},
|
||||
allCardBalance: {
|
||||
title: '全类别会员卡余额合计',
|
||||
content: '储值卡 + 赠送卡(酒水卡、台费卡、抵用券)的总余额。\n\n仅供经营参考,非财务属性。',
|
||||
},
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'normal' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
|
||||
/** AI 配色 */
|
||||
aiColorClass: '',
|
||||
|
||||
/** 时间筛选 */
|
||||
selectedTime: 'month',
|
||||
selectedTimeText: '本月',
|
||||
timeOptions: [
|
||||
{ value: 'month', text: '本月' },
|
||||
{ value: 'lastMonth', text: '上月' },
|
||||
{ value: 'week', text: '本周' },
|
||||
{ value: 'lastWeek', text: '上周' },
|
||||
{ value: 'quarter3', text: '前3个月 不含本月' },
|
||||
{ value: 'quarter', text: '本季度' },
|
||||
{ value: 'lastQuarter', text: '上季度' },
|
||||
{ value: 'half6', text: '最近6个月不含本月' },
|
||||
],
|
||||
|
||||
/** 区域筛选 */
|
||||
selectedArea: 'all',
|
||||
selectedAreaText: '全部区域',
|
||||
areaOptions: [
|
||||
{ value: 'all', text: '全部区域' },
|
||||
{ value: 'hall', text: '大厅' },
|
||||
{ value: 'hallA', text: 'A区' },
|
||||
{ value: 'hallB', text: 'B区' },
|
||||
{ value: 'hallC', text: 'C区' },
|
||||
{ value: 'mahjong', text: '麻将房' },
|
||||
{ value: 'teamBuilding', text: '团建房' },
|
||||
],
|
||||
|
||||
/** 环比开关 */
|
||||
compareEnabled: false,
|
||||
|
||||
/** 目录导航 */
|
||||
tocVisible: false,
|
||||
tocItems: [
|
||||
{ emoji: '📈', title: '经营一览', sectionId: 'section-overview' },
|
||||
{ emoji: '💳', title: '预收资产', sectionId: 'section-recharge' },
|
||||
{ emoji: '💰', title: '应计收入确认', sectionId: 'section-revenue' },
|
||||
{ emoji: '🧾', title: '现金流入', sectionId: 'section-cashflow' },
|
||||
{ emoji: '📤', title: '现金流出', sectionId: 'section-expense' },
|
||||
{ emoji: '🎱', title: '助教分析', sectionId: 'section-coach' },
|
||||
] as TocItem[],
|
||||
currentSectionIndex: 0,
|
||||
|
||||
/** P1: 吸顶板块头(H5: scaleX 从左滑入,同时筛选按钮 opacity 淡出) */
|
||||
stickyHeaderVisible: false,
|
||||
stickyHeaderEmoji: '',
|
||||
stickyHeaderTitle: '',
|
||||
stickyHeaderDesc: '',
|
||||
|
||||
/** 提示弹窗 */
|
||||
tipVisible: false,
|
||||
tipTitle: '',
|
||||
tipContent: '',
|
||||
|
||||
/** 经营一览 */
|
||||
overview: {
|
||||
occurrence: '¥823,456',
|
||||
occurrenceCompare: '12.5%',
|
||||
discount: '-¥113,336',
|
||||
discountCompare: '3.2%',
|
||||
discountRate: '13.8%',
|
||||
discountRateCompare: '1.5%',
|
||||
confirmedRevenue: '¥710,120',
|
||||
confirmedCompare: '8.7%',
|
||||
cashIn: '¥698,500',
|
||||
cashInCompare: '5.3%',
|
||||
cashOut: '¥472,300',
|
||||
cashOutCompare: '2.1%',
|
||||
cashBalance: '¥226,200',
|
||||
cashBalanceCompare: '15.2%',
|
||||
balanceRate: '32.4%',
|
||||
balanceRateCompare: '3.8%',
|
||||
},
|
||||
|
||||
/** 预收资产 */
|
||||
recharge: {
|
||||
actualIncome: '¥352,800',
|
||||
actualCompare: '18.5%',
|
||||
firstCharge: '¥188,500',
|
||||
firstChargeCompare: '12.3%',
|
||||
renewCharge: '¥164,300',
|
||||
renewChargeCompare: '8.7%',
|
||||
consumed: '¥238,200',
|
||||
consumedCompare: '5.2%',
|
||||
cardBalance: '¥642,600',
|
||||
cardBalanceCompare: '11.4%',
|
||||
giftRows: [
|
||||
{
|
||||
label: '新增', total: '¥108,600', totalCompare: '9.8%',
|
||||
wine: '¥43,200', wineCompare: '11.2%',
|
||||
table: '¥54,100', tableCompare: '8.5%',
|
||||
coupon: '¥11,300', couponCompare: '6.3%',
|
||||
},
|
||||
{
|
||||
label: '消费', total: '¥75,800', totalCompare: '7.2%',
|
||||
wine: '¥32,100', wineCompare: '8.1%',
|
||||
table: '¥32,800', tableCompare: '6.5%',
|
||||
coupon: '¥10,900', couponCompare: '5.8%',
|
||||
},
|
||||
{
|
||||
label: '余额', total: '¥243,900', totalCompare: '4.5%',
|
||||
wine: '¥118,500', wineCompare: '5.2%',
|
||||
table: '¥109,200', tableCompare: '3.8%',
|
||||
coupon: '¥16,200', couponCompare: '2.5%',
|
||||
},
|
||||
],
|
||||
allCardBalance: '¥586,500',
|
||||
allCardBalanceCompare: '6.2%',
|
||||
},
|
||||
|
||||
/** 应计收入确认 */
|
||||
revenue: {
|
||||
structureRows: [
|
||||
{ id: 'table', name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
|
||||
{ id: 'area-a', name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
|
||||
{ id: 'area-b', name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
|
||||
{ id: 'area-c', name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
|
||||
{ id: 'team', name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
|
||||
{ id: 'mahjong', name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
|
||||
{ id: 'coach-basic', name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
|
||||
{ id: 'coach-incentive', name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
|
||||
{ id: 'food', name: '食品酒水', amount: '¥119,556', discount: '-¥68,136', booked: '¥51,420', bookedCompare: '6.5%' },
|
||||
],
|
||||
priceItems: [
|
||||
{ name: '开台消费', value: '¥358,600', compare: '9.2%' },
|
||||
{ name: '酒水商品', value: '¥186,420', compare: '18.5%' },
|
||||
{ name: '包厢费用', value: '¥165,636', compare: '12.1%' },
|
||||
{ name: '助教服务', value: '¥112,800', compare: '15.3%' },
|
||||
],
|
||||
totalOccurrence: '¥823,456',
|
||||
totalOccurrenceCompare: '12.5%',
|
||||
discountItems: [
|
||||
{ name: '团购优惠', value: '-¥56,200', compare: '5.2%' },
|
||||
{ name: '手动调整 + 大客户优惠', value: '-¥34,800', compare: '3.1%' },
|
||||
{ name: '赠送卡抵扣', desc: '台桌卡+酒水卡+抵用券', value: '-¥22,336', compare: '8.6%' },
|
||||
{ name: '其他优惠', desc: '免单+抹零', value: '-¥0', compare: '' },
|
||||
],
|
||||
confirmedTotal: '¥710,120',
|
||||
confirmedTotalCompare: '8.7%',
|
||||
channelItems: [
|
||||
{ name: '储值卡结算冲销', value: '¥238,200', compare: '11.2%' },
|
||||
{ name: '现金/线上支付', value: '¥345,800', compare: '7.8%' },
|
||||
{ name: '团购核销确认收入', desc: '团购成交价', value: '¥126,120', compare: '5.3%' },
|
||||
],
|
||||
},
|
||||
|
||||
/** 现金流入 */
|
||||
cashflow: {
|
||||
consumeItems: [
|
||||
{ name: '纸币现金', desc: '柜台现金收款', value: '¥85,600', compare: '12.3%', isDown: true },
|
||||
{ name: '线上收款', desc: '微信/支付宝/刷卡 已扣除平台服务费', value: '¥260,200', compare: '8.5%', isDown: false },
|
||||
{ name: '团购平台', desc: '美团/抖音回款 已扣除平台服务费', value: '¥126,120', compare: '15.2%', isDown: false },
|
||||
],
|
||||
rechargeItems: [
|
||||
{ name: '会员充值到账', desc: '首充/续费实收', value: '¥352,800', compare: '18.5%' },
|
||||
],
|
||||
total: '¥824,720',
|
||||
totalCompare: '10.2%',
|
||||
},
|
||||
|
||||
/** 现金流出 */
|
||||
expense: {
|
||||
operationItems: [
|
||||
{ name: '食品饮料', value: '¥108,200', compare: '4.5%', isDown: false },
|
||||
{ name: '耗材', value: '¥21,850', compare: '2.1%', isDown: true },
|
||||
{ name: '报销', value: '¥10,920', compare: '6.8%', isDown: false },
|
||||
],
|
||||
fixedItems: [
|
||||
{ name: '房租', value: '¥125,000', compare: '持平', isFlat: true },
|
||||
{ name: '水电', value: '¥24,200', compare: '3.2%', isFlat: false },
|
||||
{ name: '物业', value: '¥11,500', compare: '持平', isFlat: true },
|
||||
{ name: '人员工资', value: '¥112,000', compare: '持平', isFlat: true },
|
||||
],
|
||||
coachItems: [
|
||||
{ name: '基础课分成', value: '¥116,250', compare: '8.2%', isDown: false },
|
||||
{ name: '激励课分成', value: '¥23,840', compare: '5.6%', isDown: false },
|
||||
{ name: '充值提成', value: '¥12,640', compare: '12.3%', isDown: false },
|
||||
{ name: '额外奖金', value: '¥11,500', compare: '3.1%', isDown: true },
|
||||
],
|
||||
platformItems: [
|
||||
{ name: '汇来米', value: '¥10,680', compare: '1.5%' },
|
||||
{ name: '美团', value: '¥11,240', compare: '2.8%' },
|
||||
{ name: '抖音', value: '¥10,580', compare: '3.5%' },
|
||||
],
|
||||
total: '¥600,400',
|
||||
totalCompare: '2.1%',
|
||||
},
|
||||
|
||||
/** 助教分析 */
|
||||
coachAnalysis: {
|
||||
basic: {
|
||||
totalPay: '¥232,500',
|
||||
totalPayCompare: '15.3%',
|
||||
totalShare: '¥116,250',
|
||||
totalShareCompare: '15.3%',
|
||||
avgHourly: '¥25/h',
|
||||
avgHourlyCompare: '4.2%',
|
||||
rows: [
|
||||
{ level: '初级', pay: '¥68,600', payCompare: '12.5%', share: '¥34,300', shareCompare: '12.5%', hourly: '¥20/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
{ level: '中级', pay: '¥82,400', payCompare: '18.2%', share: '¥41,200', shareCompare: '18.2%', hourly: '¥25/h', hourlyCompare: '8.7%' },
|
||||
{ level: '高级', pay: '¥57,800', payCompare: '14.6%', share: '¥28,900', shareCompare: '14.6%', hourly: '¥30/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
{ level: '星级', pay: '¥23,700', payCompare: '3.2%', payDown: true, share: '¥11,850', shareCompare: '3.2%', shareDown: true, hourly: '¥35/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
],
|
||||
},
|
||||
incentive: {
|
||||
totalPay: '¥112,800',
|
||||
totalPayCompare: '8.2%',
|
||||
totalShare: '¥33,840',
|
||||
totalShareCompare: '8.2%',
|
||||
avgHourly: '¥15/h',
|
||||
avgHourlyCompare: '2.1%',
|
||||
rows: [
|
||||
{ level: '初级', pay: '¥32,400', payCompare: '6.8%', share: '¥9,720', shareCompare: '6.8%', hourly: '¥12/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
{ level: '中级', pay: '¥38,600', payCompare: '10.5%', share: '¥11,580', shareCompare: '10.5%', hourly: '¥15/h', hourlyCompare: '5.2%' },
|
||||
{ level: '高级', pay: '¥28,200', payCompare: '7.3%', share: '¥8,460', shareCompare: '7.3%', hourly: '¥18/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
{ level: '星级', pay: '¥13,600', payCompare: '2.1%', payDown: true, share: '¥4,080', shareCompare: '2.1%', shareDown: true, hourly: '¥22/h', hourlyCompare: '持平', hourlyFlat: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
// P5: AI 配色
|
||||
const aiColor = getRandomAiColor()
|
||||
this.setData({ aiColorClass: aiColor.className })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 同步 custom-tab-bar 选中态
|
||||
const tabBar = this.getTabBar?.()
|
||||
if (tabBar) tabBar.setData({ active: 'board' })
|
||||
// TODO: 联调时在此刷新看板数据
|
||||
},
|
||||
|
||||
onReady() {
|
||||
// P1: 缓存各 section 的 top 位置
|
||||
this._cacheSectionPositions()
|
||||
},
|
||||
|
||||
/** P1/P2: 页面滚动监听(节流 100ms)— 匹配 H5 原型行为 */
|
||||
/* CHANGE 2026-03-13 | intent: H5 原型下滑→显示吸顶头+隐藏筛选按钮,上滑→隐藏吸顶头+恢复筛选按钮;不再使用独立的 filterBarHidden 状态 */
|
||||
onPageScroll(e: { scrollTop: number }) {
|
||||
const now = Date.now()
|
||||
if (now - this._lastScrollTime < 100) return
|
||||
this._lastScrollTime = now
|
||||
|
||||
const scrollTop = e.scrollTop
|
||||
const isScrollingDown = scrollTop > this._lastScrollTop
|
||||
this._lastScrollTop = scrollTop
|
||||
|
||||
// P1: 吸顶板块头 — 与 H5 updateStickyHeader 逻辑对齐
|
||||
if (this._sectionTops.length === 0) return
|
||||
|
||||
// 偏移量:tabs(~78rpx) + filter-bar(~70rpx) 约 148rpx ≈ 93px,取 100 作为阈值
|
||||
const offset = 100
|
||||
let currentIdx = 0
|
||||
for (let i = this._sectionTops.length - 1; i >= 0; i--) {
|
||||
if (scrollTop + offset >= this._sectionTops[i]) {
|
||||
currentIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// H5: scrollY < 80 时隐藏吸顶头
|
||||
if (scrollTop < 80) {
|
||||
if (this.data.stickyHeaderVisible) {
|
||||
this.setData({ stickyHeaderVisible: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const toc = this.data.tocItems[currentIdx]
|
||||
|
||||
if (isScrollingDown && !this.data.stickyHeaderVisible) {
|
||||
// H5: 下滑且吸顶头未显示 → 显示吸顶头(筛选按钮通过 CSS opacity 自动淡出)
|
||||
this.setData({
|
||||
stickyHeaderVisible: true,
|
||||
stickyHeaderEmoji: toc?.emoji || '',
|
||||
stickyHeaderTitle: toc?.title || '',
|
||||
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
|
||||
currentSectionIndex: currentIdx,
|
||||
})
|
||||
} else if (!isScrollingDown && this.data.stickyHeaderVisible) {
|
||||
// H5: 上滑且吸顶头显示 → 隐藏吸顶头(筛选按钮通过 CSS opacity 自动恢复)
|
||||
this.setData({ stickyHeaderVisible: false })
|
||||
} else if (this.data.stickyHeaderVisible && currentIdx !== this.data.currentSectionIndex) {
|
||||
// H5: 吸顶头显示时板块切换 → 更新内容
|
||||
this.setData({
|
||||
stickyHeaderEmoji: toc?.emoji || '',
|
||||
stickyHeaderTitle: toc?.title || '',
|
||||
stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',
|
||||
currentSectionIndex: currentIdx,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 缓存 section 位置(私有) */
|
||||
_sectionTops: [] as number[],
|
||||
_lastScrollTop: 0,
|
||||
_lastScrollTime: 0,
|
||||
|
||||
/** H5 原型吸顶头包含板块描述,从 data-section-desc 映射 */
|
||||
_sectionDescs: [
|
||||
'快速了解收入与现金流的整体健康度',
|
||||
'会员卡充值与余额 掌握资金沉淀',
|
||||
'从发生额到入账收入的全流程',
|
||||
'实际到账的资金来源明细',
|
||||
'清晰呈现各类开销与结构',
|
||||
'全部助教服务收入与分成的平均值',
|
||||
] as string[],
|
||||
|
||||
_getSectionDesc(index: number): string {
|
||||
return this._sectionDescs[index] || ''
|
||||
},
|
||||
|
||||
_cacheSectionPositions() {
|
||||
const sectionIds = this.data.tocItems.map(item => item.sectionId)
|
||||
const query = wx.createSelectorQuery().in(this)
|
||||
sectionIds.forEach(id => {
|
||||
query.select(`#${id}`).boundingClientRect()
|
||||
})
|
||||
query.exec((results: Array<WechatMiniprogram.BoundingClientRectCallbackResult | null>) => {
|
||||
if (!results) return
|
||||
this._sectionTops = results.map(r => (r ? r.top : 0))
|
||||
})
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 500)
|
||||
},
|
||||
|
||||
/** 看板 Tab 切换 */
|
||||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as string
|
||||
if (tab === 'customer') {
|
||||
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
|
||||
} else if (tab === 'coach') {
|
||||
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 时间筛选变更 */
|
||||
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const value = e.detail.value
|
||||
const option = this.data.timeOptions.find(o => o.value === value)
|
||||
this.setData({
|
||||
selectedTime: value,
|
||||
selectedTimeText: option?.text || '本月',
|
||||
})
|
||||
},
|
||||
|
||||
/** 区域筛选变更 */
|
||||
onAreaChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const value = e.detail.value
|
||||
const option = this.data.areaOptions.find(o => o.value === value)
|
||||
this.setData({
|
||||
selectedArea: value,
|
||||
selectedAreaText: option?.text || '全部区域',
|
||||
})
|
||||
// P3: 区域变更后重新缓存 section 位置(预收资产可能隐藏/显示)
|
||||
setTimeout(() => this._cacheSectionPositions(), 300)
|
||||
},
|
||||
|
||||
/** 环比开关切换 */
|
||||
toggleCompare() {
|
||||
this.setData({ compareEnabled: !this.data.compareEnabled })
|
||||
},
|
||||
|
||||
/** 目录导航开关 */
|
||||
toggleToc() {
|
||||
this.setData({ tocVisible: !this.data.tocVisible })
|
||||
},
|
||||
|
||||
closeToc() {
|
||||
this.setData({ tocVisible: false })
|
||||
},
|
||||
|
||||
/** 目录项点击 → 滚动到对应板块(P0: 使用 pageScrollTo 替代 scrollIntoView) */
|
||||
onTocItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = e.currentTarget.dataset.index as number
|
||||
const sectionId = this.data.tocItems[index]?.sectionId
|
||||
if (sectionId) {
|
||||
this.setData({
|
||||
tocVisible: false,
|
||||
currentSectionIndex: index,
|
||||
})
|
||||
wx.createSelectorQuery().in(this)
|
||||
.select(`#${sectionId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect) {
|
||||
wx.pageScrollTo({
|
||||
scrollTop: rect.top + (this._lastScrollTop || 0) - 140,
|
||||
duration: 300,
|
||||
})
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
},
|
||||
|
||||
/** 帮助图标点击 → 弹出说明 */
|
||||
onHelpTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const key = e.currentTarget.dataset.key as string
|
||||
const tip = tipContents[key]
|
||||
if (tip) {
|
||||
this.setData({
|
||||
tipVisible: true,
|
||||
tipTitle: tip.title,
|
||||
tipContent: tip.content,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 关闭提示弹窗 */
|
||||
closeTip() {
|
||||
this.setData({ tipVisible: false })
|
||||
},
|
||||
|
||||
/** P4: 错误态重试 */
|
||||
onRetry() {
|
||||
this.setData({ pageState: 'normal' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,782 @@
|
||||
<!-- 财务看板页 — 忠于 H5 原型结构 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-empty description="暂无财务数据" />
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-empty description="加载失败" />
|
||||
<view class="retry-btn" bindtap="onRetry">
|
||||
<text class="retry-btn-text">点击重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:else>
|
||||
<!-- 顶部看板 Tab 导航 -->
|
||||
<view class="board-tabs">
|
||||
<view class="board-tab board-tab--active" data-tab="finance">
|
||||
<text>财务</text>
|
||||
</view>
|
||||
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
|
||||
<text>客户</text>
|
||||
</view>
|
||||
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
|
||||
<text>助教</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<!-- CHANGE 2026-03-13 | intent: H5 原型筛选按钮与吸顶板块头共存于同一容器,通过 opacity/scaleX 动画切换;目录按钮始终可见,只有筛选按钮和环比开关淡出 -->
|
||||
<view class="filter-bar">
|
||||
<view class="filter-bar-inner">
|
||||
<!-- 目录按钮(始终可见,不受吸顶头影响) -->
|
||||
<view class="toc-btn" bindtap="toggleToc">
|
||||
<t-icon name="view-list" size="40rpx" color="#ffffff" />
|
||||
</view>
|
||||
|
||||
<!-- 时间筛选(吸顶头显示时淡出) -->
|
||||
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
|
||||
<filter-dropdown
|
||||
label="本月"
|
||||
options="{{timeOptions}}"
|
||||
value="{{selectedTime}}"
|
||||
bind:change="onTimeChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 区域筛选(吸顶头显示时淡出) -->
|
||||
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
|
||||
<filter-dropdown
|
||||
label="全部区域"
|
||||
options="{{areaOptions}}"
|
||||
value="{{selectedArea}}"
|
||||
bind:change="onAreaChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 环比开关(始终可见,不受吸顶头影响) -->
|
||||
<view class="compare-switch" bindtap="toggleCompare">
|
||||
<text class="compare-label">环比</text>
|
||||
<view class="compare-toggle {{compareEnabled ? 'compare-toggle--active' : ''}}">
|
||||
<view class="compare-toggle-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 吸顶板块头(滚动到非首屏时从左滑入,覆盖在筛选按钮上方) -->
|
||||
<view class="sticky-section-header {{stickyHeaderVisible ? 'sticky-section-header--show' : ''}}">
|
||||
<text class="sticky-header-emoji">{{stickyHeaderEmoji}}</text>
|
||||
<view class="sticky-header-content">
|
||||
<text class="sticky-header-title">{{stickyHeaderTitle}}</text>
|
||||
<text class="sticky-header-desc">{{stickyHeaderDesc}}</text>
|
||||
</view>
|
||||
<view class="sticky-header-tags">
|
||||
<text class="sticky-header-tag" wx:if="{{selectedTimeText !== '本月'}}">{{selectedTimeText}}</text>
|
||||
<text class="sticky-header-tag" wx:if="{{selectedAreaText !== '全部区域'}}">{{selectedAreaText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容区(页面自然滚动) -->
|
||||
<view class="board-content">
|
||||
|
||||
<!-- ===== 板块 1: 经营一览(深色) ===== -->
|
||||
<view id="section-overview" class="card-section section-dark">
|
||||
<view class="card-header-dark">
|
||||
<text class="card-header-emoji">📈</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-dark">经营一览</text>
|
||||
<text class="card-header-desc-dark">快速了解收入与现金流的整体健康度</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入概览 -->
|
||||
<view class="sub-section-label">
|
||||
<text class="sub-label-text">收入概览</text>
|
||||
<text class="sub-label-desc">记账口径收入与优惠</text>
|
||||
</view>
|
||||
|
||||
<view class="overview-grid-3">
|
||||
<view class="overview-cell">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">发生额/正价</text>
|
||||
<view class="help-icon-light" data-key="occurrence" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-white">{{overview.occurrence}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.occurrenceCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-cell">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">总优惠</text>
|
||||
<view class="help-icon-light" data-key="discount" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-red">{{overview.discount}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-down">↓{{overview.discountCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-cell">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">优惠占比</text>
|
||||
</view>
|
||||
<text class="cell-value-gray">{{overview.discountRate}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-down">↓{{overview.discountRateCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 成交/确认收入 -->
|
||||
<view class="confirmed-row">
|
||||
<view class="confirmed-left">
|
||||
<text class="confirmed-label">成交/确认收入</text>
|
||||
<view class="help-icon-light" data-key="confirmed" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<view class="confirmed-right">
|
||||
<text class="confirmed-value">{{overview.confirmedRevenue}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.confirmedCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-divider-light"></view>
|
||||
|
||||
<!-- 现金流水概览 -->
|
||||
<view class="sub-section-label">
|
||||
<text class="sub-label-text">现金流水概览</text>
|
||||
<text class="sub-label-desc">往期为已结算 本期为截至当前的发生额</text>
|
||||
</view>
|
||||
|
||||
<view class="overview-grid-2">
|
||||
<view class="overview-cell-bg">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">实收/现金流入</text>
|
||||
<view class="help-icon-light" data-key="cashIn" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-white-sm">{{overview.cashIn}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.cashInCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-cell-bg">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">现金支出</text>
|
||||
<view class="help-icon-light" data-key="cashOut" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-gray-sm">{{overview.cashOut}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.cashOutCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-cell-bg">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">现金结余</text>
|
||||
<view class="help-icon-light" data-key="balance" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-white-sm">{{overview.cashBalance}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.cashBalanceCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-cell-bg">
|
||||
<view class="cell-label-row">
|
||||
<text class="cell-label-light">结余率</text>
|
||||
</view>
|
||||
<text class="cell-value-white-sm">{{overview.balanceRate}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up">↑{{overview.balanceRateCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 洞察 -->
|
||||
<!-- CHANGE 2026-03-12 | intent: H5 原型使用 SVG 机器人图标,不可用 emoji 替代;规范要求内联 SVG 导出为文件用 image 引用 -->
|
||||
<view class="ai-insight-section">
|
||||
<view class="ai-insight-header">
|
||||
<view class="ai-insight-icon">
|
||||
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" class="ai-insight-icon-img" />
|
||||
</view>
|
||||
<text class="ai-insight-title">AI 智能洞察</text>
|
||||
</view>
|
||||
<view class="ai-insight-body">
|
||||
<text class="ai-insight-line"><text class="ai-insight-dim">优惠率Top:</text>团购(5.2%) / 大客户(3.1%) / 赠送卡(2.5%)</text>
|
||||
<text class="ai-insight-line"><text class="ai-insight-dim">差异最大:</text>酒水(+18%) / 台桌(-5%) / 包厢(+12%)</text>
|
||||
<!-- CHANGE 2026-03-12 | intent: H5 原型第三行"充值高但消耗低"有 underline 样式 -->
|
||||
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text><text class="ai-insight-underline">充值高但消耗低</text>,会员活跃度需提升</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 板块 2: 预收资产(仅"全部区域"时显示) ===== -->
|
||||
<view id="section-recharge" class="card-section" wx:if="{{selectedArea === 'all'}}">
|
||||
<view class="card-header-light">
|
||||
<text class="card-header-emoji">💳</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-light">预收资产</text>
|
||||
<text class="card-header-desc-light">会员卡充值与余额 掌握资金沉淀 辅助会员运营策略定制</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 储值卡统计 -->
|
||||
<view class="section-body">
|
||||
<!-- 全类别会员卡余额合计 -->
|
||||
<!-- CHANGE 2026-03-13 | intent: H5 中 label 和 help-icon 在同一行(flex items-center mb-1),MP 需要用 view 包裹实现同行布局 -->
|
||||
<view class="total-balance-row">
|
||||
<view class="total-balance-left">
|
||||
<view class="total-balance-label">
|
||||
<text>全类别会员卡余额合计</text>
|
||||
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="total-balance-note">仅经营参考,非财务属性</text>
|
||||
</view>
|
||||
<view class="total-balance-right">
|
||||
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{recharge.allCardBalanceCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="card-section-title">储值卡统计</text>
|
||||
<view class="table-bordered">
|
||||
<!-- 行1:储值卡充值实收 -->
|
||||
<view class="table-row table-row--highlight">
|
||||
<view class="table-row-left">
|
||||
<text class="table-row-label-bold">储值卡充值实收</text>
|
||||
<view class="help-icon-dark" data-key="rechargeActual" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<view class="table-row-right">
|
||||
<text class="table-row-value-lg">{{recharge.actualIncome}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{recharge.actualCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 行2:首充/续费/消耗 三列 -->
|
||||
<view class="table-row-grid3">
|
||||
<view class="grid-cell">
|
||||
<view class="cell-label-row-sm">
|
||||
<text class="cell-label-sm">首充</text>
|
||||
<view class="help-icon-dark-sm" data-key="firstCharge" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-sm">{{recharge.firstCharge}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{recharge.firstChargeCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<view class="cell-label-row-sm">
|
||||
<text class="cell-label-sm">续费</text>
|
||||
<view class="help-icon-dark-sm" data-key="renewCharge" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-sm">{{recharge.renewCharge}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{recharge.renewChargeCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="grid-cell">
|
||||
<view class="cell-label-row-sm">
|
||||
<text class="cell-label-sm">消耗</text>
|
||||
<view class="help-icon-dark-sm" data-key="consume" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<text class="cell-value-sm">{{recharge.consumed}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{recharge.consumedCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 行3:储值卡总余额 -->
|
||||
<view class="table-row table-row--footer">
|
||||
<view class="table-row-left">
|
||||
<text class="table-row-label-bold">储值卡总余额</text>
|
||||
<view class="help-icon-dark" data-key="cardBalance" bindtap="onHelpTap">?</view>
|
||||
</view>
|
||||
<view class="table-row-right">
|
||||
<text class="table-row-value-lg">{{recharge.cardBalance}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{recharge.cardBalanceCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 赠送卡统计详情 -->
|
||||
<text class="card-section-title" style="margin-top: 35rpx;">赠送卡统计详情</text>
|
||||
<view class="table-bordered">
|
||||
<!-- 表头 -->
|
||||
<view class="gift-table-header">
|
||||
<text class="gift-col gift-col--name">类型</text>
|
||||
<text class="gift-col">酒水卡</text>
|
||||
<text class="gift-col">台费卡</text>
|
||||
<text class="gift-col">抵用券</text>
|
||||
</view>
|
||||
<!-- 数据行(新增/消费/余额) -->
|
||||
<view class="gift-table-row {{compareEnabled ? 'gift-table-row--compare' : ''}}" wx:for="{{recharge.giftRows}}" wx:key="label">
|
||||
<!-- 左列:标题 + 环比 / 总金额 -->
|
||||
<view class="gift-col gift-col--name">
|
||||
<view class="gift-label-line">
|
||||
<text class="gift-row-label">{{item.label}}</text>
|
||||
<text class="compare-text-up-xs" wx:if="{{compareEnabled}}">↑{{item.totalCompare}}</text>
|
||||
</view>
|
||||
<text class="gift-row-total">{{item.total}}</text>
|
||||
</view>
|
||||
<!-- 酒水卡 -->
|
||||
<view class="gift-col">
|
||||
<text class="gift-col-val">{{item.wine}}</text>
|
||||
<view class="gift-label-line" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.wineCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 台费卡 -->
|
||||
<view class="gift-col">
|
||||
<text class="gift-col-val">{{item.table}}</text>
|
||||
<view class="gift-label-line" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.tableCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 抵用券 -->
|
||||
<view class="gift-col">
|
||||
<text class="gift-col-val">{{item.coupon}}</text>
|
||||
<view class="gift-label-line" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.couponCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
</view>
|
||||
<!-- 底部锯齿(模拟 H5 card-section::after) -->
|
||||
<view class="card-tear"></view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 板块 3: 应计收入确认 ===== -->
|
||||
<view id="section-revenue" class="card-section">
|
||||
<view class="card-header-light">
|
||||
<text class="card-header-emoji">💰</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-light">【记账】应计收入确认</text>
|
||||
<text class="card-header-desc-light">从发生额到入账收入的全流程</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-body">
|
||||
<!-- 收入结构 -->
|
||||
<view class="sub-title-row">
|
||||
<text class="sub-title-text">收入结构</text>
|
||||
<text class="sub-title-desc">按业务查看各项应计收入的构成</text>
|
||||
</view>
|
||||
<view class="table-bordered">
|
||||
<!-- 表头 -->
|
||||
<view class="rev-table-header">
|
||||
<text class="rev-col rev-col--name">项目</text>
|
||||
<text class="rev-col">发生额</text>
|
||||
<text class="rev-col">优惠</text>
|
||||
<text class="rev-col">入账</text>
|
||||
</view>
|
||||
<!-- 数据行 -->
|
||||
<block wx:for="{{revenue.structureRows}}" wx:key="id">
|
||||
<view class="rev-table-row {{item.isSub ? 'rev-table-row--sub' : ''}}">
|
||||
<view class="rev-col rev-col--name">
|
||||
<text class="{{item.isSub ? 'rev-name-sub' : 'rev-name'}}">{{item.name}}</text>
|
||||
<text class="rev-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
|
||||
</view>
|
||||
<text class="rev-col rev-val">{{item.amount}}</text>
|
||||
<text class="rev-col rev-val {{item.discount !== '-' ? 'rev-val--red' : 'rev-val--muted'}}">{{item.discount}}</text>
|
||||
<view class="rev-col">
|
||||
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{item.booked}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled && item.bookedCompare}}">
|
||||
<text class="compare-text-up-xs">↑{{item.bookedCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 收入确认(损益链) -->
|
||||
<!-- CHANGE 2026-03-13 | intent: H5 收入结构外层 mb-5=20px→36rpx(87.5%取偶),损益链与收入结构之间的间距 -->
|
||||
<view class="sub-title-row" style="margin-top: 36rpx;">
|
||||
<text class="sub-title-text">收入确认</text>
|
||||
<text class="sub-title-desc">从正价到收款方式的损益链</text>
|
||||
</view>
|
||||
<view class="table-bordered">
|
||||
<!-- 项目正价 标题 -->
|
||||
<view class="flow-header">
|
||||
<text class="flow-header-title">项目正价</text>
|
||||
<text class="flow-header-desc">即标价测算</text>
|
||||
</view>
|
||||
<!-- 正价明细(左侧竖线) -->
|
||||
<view class="flow-detail-list">
|
||||
<view class="flow-detail-item" wx:for="{{revenue.priceItems}}" wx:key="name">
|
||||
<text class="flow-detail-name">{{item.name}}</text>
|
||||
<view class="flow-detail-right">
|
||||
<text class="flow-detail-val">{{item.value}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 发生额合计 -->
|
||||
<view class="flow-total-row">
|
||||
<view class="flow-total-left">
|
||||
<text class="flow-total-label">发生额</text>
|
||||
<text class="flow-total-desc">即上列正价合计</text>
|
||||
</view>
|
||||
<view class="flow-total-right">
|
||||
<text class="flow-total-value">{{revenue.totalOccurrence}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{revenue.totalOccurrenceCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 优惠扣减 -->
|
||||
<view class="flow-header flow-header--deduct">
|
||||
<text class="flow-header-title">优惠扣减</text>
|
||||
</view>
|
||||
<view class="flow-detail-list">
|
||||
<view class="flow-detail-item" wx:for="{{revenue.discountItems}}" wx:key="name">
|
||||
<view class="flow-detail-name-group">
|
||||
<text class="flow-detail-name">{{item.name}}</text>
|
||||
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
|
||||
</view>
|
||||
<view class="flow-detail-right">
|
||||
<text class="flow-detail-val {{item.value === '-¥0' ? 'flow-detail-val--muted' : 'flow-detail-val--red'}}">{{item.value}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled && item.compare}}">
|
||||
<text class="compare-text-down-xs">↓{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 成交收入 -->
|
||||
<view class="flow-total-row flow-total-row--accent">
|
||||
<view class="flow-total-left">
|
||||
<text class="flow-total-label">成交/确认收入</text>
|
||||
<text class="flow-total-desc">即发生额扣除以上优惠抵扣后的金额</text>
|
||||
<text class="flow-total-desc">此金额收款渠道分布如下</text>
|
||||
</view>
|
||||
<view class="flow-total-right">
|
||||
<text class="flow-total-value">{{revenue.confirmedTotal}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{revenue.confirmedTotalCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 收款渠道 -->
|
||||
<view class="flow-header">
|
||||
<text class="flow-header-title">收款渠道明细</text>
|
||||
</view>
|
||||
<view class="flow-detail-list">
|
||||
<view class="flow-detail-item" wx:for="{{revenue.channelItems}}" wx:key="name">
|
||||
<view class="flow-detail-name-group">
|
||||
<text class="flow-detail-name">{{item.name}}</text>
|
||||
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
|
||||
</view>
|
||||
<view class="flow-detail-right">
|
||||
<text class="flow-detail-val">{{item.value}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部锯齿(模拟 H5 card-section::after) -->
|
||||
<view class="card-tear"></view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 板块 4: 现金流入 ===== -->
|
||||
<view id="section-cashflow" class="card-section">
|
||||
<view class="card-header-light">
|
||||
<text class="card-header-emoji">🧾</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-light">【现金流水】流入</text>
|
||||
<text class="card-header-desc-light">实际到账的资金来源明细</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-body">
|
||||
<!-- 消费收入 -->
|
||||
<text class="flow-group-label">消费收入</text>
|
||||
<view class="flow-item-list">
|
||||
<view class="flow-item" wx:for="{{cashflow.consumeItems}}" wx:key="name">
|
||||
<view class="flow-item-left">
|
||||
<text class="flow-item-name">{{item.name}}</text>
|
||||
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
|
||||
</view>
|
||||
<view class="flow-item-right">
|
||||
<text class="flow-item-value">{{item.value}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 充值收入 -->
|
||||
<!-- CHANGE 2026-03-13 | intent: H5 mt-3=12px→22rpx(87.5%取偶) -->
|
||||
<text class="flow-group-label" style="margin-top: 22rpx;">充值收入</text>
|
||||
<view class="flow-item-list">
|
||||
<view class="flow-item" wx:for="{{cashflow.rechargeItems}}" wx:key="name">
|
||||
<view class="flow-item-left">
|
||||
<text class="flow-item-name">{{item.name}}</text>
|
||||
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
|
||||
</view>
|
||||
<view class="flow-item-right">
|
||||
<text class="flow-item-value">{{item.value}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 合计 -->
|
||||
<view class="flow-sum-row">
|
||||
<text class="flow-sum-label">现金流入合计</text>
|
||||
<view class="flow-sum-right">
|
||||
<text class="flow-sum-value">{{cashflow.total}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{cashflow.totalCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部锯齿(模拟 H5 card-section::after) -->
|
||||
<view class="card-tear"></view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 板块 5: 现金流出 ===== -->
|
||||
<view id="section-expense" class="card-section">
|
||||
<view class="card-header-light">
|
||||
<text class="card-header-emoji">📤</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-light">【现金流水】流出</text>
|
||||
<text class="card-header-desc-light">清晰呈现各类开销与结构</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-body">
|
||||
<!-- 进货与运营 3列 -->
|
||||
<text class="expense-group-label">进货与运营</text>
|
||||
<view class="expense-grid-3">
|
||||
<view class="expense-cell" wx:for="{{expense.operationItems}}" wx:key="name">
|
||||
<text class="expense-cell-label">{{item.name}}</text>
|
||||
<text class="expense-cell-value">{{item.value}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 固定支出 2×2 -->
|
||||
<text class="expense-group-label">固定支出</text>
|
||||
<view class="expense-grid-2">
|
||||
<view class="expense-cell" wx:for="{{expense.fixedItems}}" wx:key="name">
|
||||
<text class="expense-cell-label">{{item.name}}</text>
|
||||
<text class="expense-cell-value">{{item.value}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.isFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.isFlat ? '' : '↑'}}{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 助教薪资 2×2 -->
|
||||
<text class="expense-group-label">助教薪资</text>
|
||||
<view class="expense-grid-2">
|
||||
<view class="expense-cell" wx:for="{{expense.coachItems}}" wx:key="name">
|
||||
<text class="expense-cell-label">{{item.name}}</text>
|
||||
<text class="expense-cell-value">{{item.value}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 平台服务费 3列 -->
|
||||
<text class="expense-group-label">平台服务费</text>
|
||||
<text class="expense-group-note">服务费在流水流入时,平台已经扣除。不产生支出流水。</text>
|
||||
<view class="expense-grid-3">
|
||||
<view class="expense-cell" wx:for="{{expense.platformItems}}" wx:key="name">
|
||||
<text class="expense-cell-label">{{item.name}}</text>
|
||||
<text class="expense-cell-value">{{item.value}}</text>
|
||||
<view class="compare-row" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{item.compare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 支出合计 -->
|
||||
<view class="flow-sum-row">
|
||||
<text class="flow-sum-label">支出合计</text>
|
||||
<view class="flow-sum-right">
|
||||
<text class="flow-sum-value">{{expense.total}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-sm">↑{{expense.totalCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部锯齿(模拟 H5 card-section::after) -->
|
||||
<view class="card-tear"></view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 板块 6: 助教分析 ===== -->
|
||||
<view id="section-coach" class="card-section">
|
||||
<view class="card-header-light">
|
||||
<text class="card-header-emoji">🎱</text>
|
||||
<view class="card-header-text">
|
||||
<text class="card-header-title-light">助教分析</text>
|
||||
<text class="card-header-desc-light">全部助教服务收入与分成的平均值,用以评估球房分成效益</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-body">
|
||||
<!-- 基础课 -->
|
||||
<text class="card-section-title">助教 <text class="card-section-title-sub">(基础课)</text></text>
|
||||
<view class="table-bordered">
|
||||
<view class="coach-fin-header">
|
||||
<text class="coach-fin-col coach-fin-col--name">级别</text>
|
||||
<text class="coach-fin-col">客户支付</text>
|
||||
<text class="coach-fin-col">球房抽成</text>
|
||||
<text class="coach-fin-col">小时平均</text>
|
||||
</view>
|
||||
<!-- 合计行 -->
|
||||
<view class="coach-fin-row coach-fin-row--total">
|
||||
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-bold">{{coachAnalysis.basic.totalPay}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalPayCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-bold">{{coachAnalysis.basic.totalShare}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalShareCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-val-sm">{{coachAnalysis.basic.avgHourly}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.avgHourlyCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 明细行 -->
|
||||
<view class="coach-fin-row coach-fin-row--detail" wx:for="{{coachAnalysis.basic.rows}}" wx:key="level">
|
||||
<text class="coach-fin-col coach-fin-col--name">{{item.level}}</text>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-val">{{item.pay}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.payDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.payDown ? '↓' : '↑'}}{{item.payCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-val">{{item.share}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.shareDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.shareDown ? '↓' : '↑'}}{{item.shareCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-val-sm">{{item.hourly}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="{{item.hourlyFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.hourlyFlat ? '' : '↑'}}{{item.hourlyCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 激励课 -->
|
||||
<text class="card-section-title" style="margin-top: 28rpx;">助教 <text class="card-section-title-sub">(激励课)</text></text>
|
||||
<view class="table-bordered">
|
||||
<view class="coach-fin-header">
|
||||
<text class="coach-fin-col coach-fin-col--name">级别</text>
|
||||
<text class="coach-fin-col">客户支付</text>
|
||||
<text class="coach-fin-col">球房抽成</text>
|
||||
<text class="coach-fin-col">小时平均</text>
|
||||
</view>
|
||||
<view class="coach-fin-row coach-fin-row--incentive-total">
|
||||
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalPay}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalPayCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalShare}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalShareCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="coach-fin-col">
|
||||
<text class="coach-fin-val-sm">{{coachAnalysis.incentive.avgHourly}}</text>
|
||||
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
|
||||
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.avgHourlyCompare}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部锯齿(模拟 H5 card-section::after) -->
|
||||
<view class="card-tear"></view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区 -->
|
||||
<view class="safe-bottom"></view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 目录导航遮罩 ===== -->
|
||||
<view class="toc-overlay" wx:if="{{tocVisible}}" catchtap="closeToc"></view>
|
||||
|
||||
<!-- ===== 目录导航面板 ===== -->
|
||||
<view class="toc-panel {{tocVisible ? 'toc-panel--show' : ''}}">
|
||||
<view class="toc-header">
|
||||
<text class="toc-header-text">📊 财务看板导航</text>
|
||||
</view>
|
||||
<view class="toc-list">
|
||||
<view
|
||||
class="toc-item {{currentSectionIndex === index ? 'toc-item--active' : ''}}"
|
||||
wx:for="{{tocItems}}"
|
||||
wx:key="sectionId"
|
||||
data-index="{{index}}"
|
||||
bindtap="onTocItemTap"
|
||||
>
|
||||
<text class="toc-item-emoji">{{item.emoji}}</text>
|
||||
<text class="toc-item-text">{{item.title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 指标说明弹窗 ===== -->
|
||||
<view class="tip-overlay" wx:if="{{tipVisible}}" catchtap="closeTip"></view>
|
||||
<view class="tip-toast {{tipVisible ? 'tip-toast--show' : ''}}">
|
||||
<view class="tip-toast-header">
|
||||
<text class="tip-toast-title">{{tipTitle}}</text>
|
||||
<view class="tip-toast-close" bindtap="closeTip">
|
||||
<t-icon name="close" size="36rpx" color="#8b8b8b" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="tip-toast-content">{{tipContent}}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button bottom="{{200}}" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"navigationBarTitleText": "对话历史",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mockChatHistory } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
|
||||
/** VI 规范 §6.2:AI 图标配色系统(6种) */
|
||||
const ICON_GRADIENTS = [
|
||||
'linear-gradient(135deg, #667eea 0%, #4a5fc7 100%)', // indigo
|
||||
'linear-gradient(135deg, #764ba2 0%, #5b3080 100%)', // purple
|
||||
'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', // red
|
||||
'linear-gradient(135deg, #e67e22 0%, #ca6c17 100%)', // orange
|
||||
'linear-gradient(135deg, #d4a017 0%, #b8860b 100%)', // yellow
|
||||
'linear-gradient(135deg, #2980b9 0%, #1a5276 100%)', // blue
|
||||
]
|
||||
|
||||
/** 带展示标签的对话历史项 */
|
||||
interface ChatHistoryDisplay {
|
||||
id: string
|
||||
title: string
|
||||
lastMessage: string
|
||||
timestamp: string
|
||||
customerName?: string
|
||||
/** 格式化后的时间标签 */
|
||||
timeLabel: string
|
||||
/** 图标背景渐变(VI §6.2 AI 图标配色,每条随机) */
|
||||
iconGradient: string
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
/** 页面状态:loading / empty / normal / error */
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
/** 状态栏高度 */
|
||||
statusBarHeight: 0,
|
||||
/** 对话历史列表 */
|
||||
list: [] as ChatHistoryDisplay[],
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight: sysInfo.statusBarHeight || 44 })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 加载数据 */
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const sorted = sortByTimestamp(mockChatHistory)
|
||||
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
|
||||
...item,
|
||||
timeLabel: formatRelativeTime(item.timestamp),
|
||||
iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)],
|
||||
}))
|
||||
|
||||
this.setData({
|
||||
list,
|
||||
pageState: list.length === 0 ? 'empty' : 'normal',
|
||||
})
|
||||
}, 400)
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 点击对话记录 → 跳转 chat 页面 */
|
||||
onItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
wx.navigateTo({ url: '/pages/chat/chat?historyId=' + id })
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 600)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<!-- pages/chat-history/chat-history.wxml — 对话历史 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<view class="error-content">
|
||||
<text class="error-icon">😵</text>
|
||||
<text class="error-text">加载失败,请重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text class="retry-btn-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty-wrap" wx:elif="{{pageState === 'empty'}}">
|
||||
<view class="page-empty">
|
||||
<t-empty description="暂无对话记录" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<view class="page-normal" wx:elif="{{pageState === 'normal'}}">
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<view class="chat-list">
|
||||
<view
|
||||
class="chat-item"
|
||||
hover-class="chat-item--hover"
|
||||
wx:for="{{list}}"
|
||||
wx:key="id"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<view class="chat-icon-box" style="background: {{item.iconGradient}};">
|
||||
<t-icon name="chat" size="40rpx" color="#ffffff" />
|
||||
</view>
|
||||
<view class="chat-content">
|
||||
<view class="chat-top">
|
||||
<text class="chat-title text-ellipsis">{{item.title}}</text>
|
||||
<text class="chat-time">{{item.timeLabel}}</text>
|
||||
</view>
|
||||
<view class="chat-bottom">
|
||||
<text class="chat-summary text-ellipsis" wx:if="{{item.customerName}}">{{item.customerName}} · {{item.lastMessage}}</text>
|
||||
<text class="chat-summary text-ellipsis" wx:else>{{item.lastMessage}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="list-footer">
|
||||
<text class="footer-text">— 已加载全部记录 —</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
@@ -0,0 +1,177 @@
|
||||
/* pages/chat-history/chat-history.wxss — 对话历史页样式 */
|
||||
|
||||
/* ========== 自定义导航栏 ========== */
|
||||
.safe-area-top {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.custom-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nav-back--hover {
|
||||
background-color: var(--color-gray-2);
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-13);
|
||||
}
|
||||
|
||||
/* ========== 加载态 & 空态 ========== */
|
||||
.page-loading,
|
||||
.page-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
/* ========== 错误态 ========== */
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--color-gray-1);
|
||||
}
|
||||
|
||||
.error-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 64rpx;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--font-base);
|
||||
color: var(--color-gray-8);
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.retry-btn--hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.retry-btn-text {
|
||||
font-size: var(--font-sm);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ========== 容器 ========== */
|
||||
.page-empty-wrap,
|
||||
.page-normal {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* ========== 对话列表 ========== */
|
||||
.chat-list {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
gap: 24rpx;
|
||||
border-bottom: 2rpx solid var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
.chat-item--hover {
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
/* 图标容器 */
|
||||
.chat-icon-box {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.2);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: var(--font-sm, 28rpx);
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-13, #242424);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-time {
|
||||
font-size: var(--font-xs, 24rpx);
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.chat-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-summary {
|
||||
font-size: var(--font-xs, 24rpx);
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ========== 底部提示 ========== */
|
||||
.list-footer {
|
||||
text-align: center;
|
||||
padding: 32rpx 0 64rpx;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-5, #c5c5c5);
|
||||
}
|
||||
10
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.json
Normal file
10
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"navigationBarTitleText": "AI 助手",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
221
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.ts
Normal file
221
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// pages/chat/chat.ts — AI 对话页
|
||||
import { mockChatMessages } from '../../utils/mock-data'
|
||||
import type { ChatMessage } from '../../utils/mock-data'
|
||||
import { simulateStreamOutput } from '../../utils/chat'
|
||||
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
|
||||
|
||||
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
|
||||
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
if (!data) return []
|
||||
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
|
||||
}
|
||||
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片)*/
|
||||
function enrichMessages(msgs: ChatMessage[]) {
|
||||
return msgs.map((m, i) => ({
|
||||
...m,
|
||||
timeLabel: formatRelativeTime(m.timestamp),
|
||||
imTimeLabel: formatIMTime(m.timestamp),
|
||||
showTimeDivider: shouldShowTimeDivider(
|
||||
i === 0 ? null : msgs[i - 1].timestamp,
|
||||
m.timestamp,
|
||||
),
|
||||
referenceCard: m.referenceCard
|
||||
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Mock AI 回复模板 */
|
||||
const mockAIReplies = [
|
||||
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
|
||||
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
|
||||
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
/** 页面状态 */
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
/** 状态栏高度 */
|
||||
statusBarHeight: 0,
|
||||
/** 消息列表 */
|
||||
messages: [] as Array<ChatMessage & {
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
|
||||
}>,
|
||||
/** 输入框内容 */
|
||||
inputText: '',
|
||||
/** AI 正在流式回复 */
|
||||
isStreaming: false,
|
||||
/** 流式输出中的内容 */
|
||||
streamingContent: '',
|
||||
/** 滚动锚点 */
|
||||
scrollToId: '',
|
||||
/** 页面顶部引用卡片(从其他页面跳转时) */
|
||||
referenceCard: null as { title: string; summary: string } | null,
|
||||
/** 客户 ID */
|
||||
customerId: '',
|
||||
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
|
||||
inputBarBottom: 0,
|
||||
},
|
||||
|
||||
/** 消息计数器,用于生成唯一 ID */
|
||||
_msgCounter: 0,
|
||||
|
||||
onLoad(options) {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
const customerId = options?.customerId || ''
|
||||
this.setData({
|
||||
customerId,
|
||||
statusBarHeight: sysInfo.statusBarHeight || 44,
|
||||
})
|
||||
this.loadMessages(customerId)
|
||||
},
|
||||
|
||||
/** 加载消息(Mock) */
|
||||
loadMessages(customerId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const messages = enrichMessages(mockChatMessages)
|
||||
this._msgCounter = messages.length
|
||||
|
||||
// 如果携带 customerId,显示引用卡片
|
||||
const referenceCard = customerId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` }
|
||||
: null
|
||||
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
}, 500)
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadMessages(this.data.customerId)
|
||||
},
|
||||
|
||||
/** 软键盘弹出:输入栏上移至键盘顶部 */
|
||||
onInputFocus(e: WechatMiniprogram.InputFocus) {
|
||||
const keyboardHeight = e.detail.height || 0
|
||||
this.setData({ inputBarBottom: keyboardHeight })
|
||||
setTimeout(() => this.scrollToBottom(), 120)
|
||||
},
|
||||
|
||||
/** 软键盘收起:输入栏归位 */
|
||||
onInputBlur() {
|
||||
this.setData({ inputBarBottom: 0 })
|
||||
},
|
||||
|
||||
/** 输入框内容变化 */
|
||||
onInputChange(e: WechatMiniprogram.Input) {
|
||||
this.setData({ inputText: e.detail.value })
|
||||
},
|
||||
|
||||
/** 发送消息 */
|
||||
onSendMessage() {
|
||||
const text = this.data.inputText.trim()
|
||||
if (!text || this.data.isStreaming) return
|
||||
|
||||
this._msgCounter++
|
||||
const now = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const userMsg = {
|
||||
id: `msg-user-${this._msgCounter}`,
|
||||
role: 'user' as const,
|
||||
content: text,
|
||||
timestamp: now,
|
||||
timeLabel: '刚刚',
|
||||
imTimeLabel: formatIMTime(now),
|
||||
showTimeDivider: shouldShowTimeDivider(prevTs, now),
|
||||
}
|
||||
|
||||
const messages = [...this.data.messages, userMsg]
|
||||
this.setData({
|
||||
messages,
|
||||
inputText: '',
|
||||
pageState: 'normal',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
setTimeout(() => {
|
||||
this.triggerAIReply()
|
||||
}, 300)
|
||||
},
|
||||
|
||||
/** 触发 AI 流式回复 */
|
||||
triggerAIReply() {
|
||||
this._msgCounter++
|
||||
const aiMsgId = `msg-ai-${this._msgCounter}`
|
||||
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
|
||||
|
||||
const aiNow = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const aiMsg = {
|
||||
id: aiMsgId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
timestamp: aiNow,
|
||||
timeLabel: '刚刚',
|
||||
imTimeLabel: formatIMTime(aiNow),
|
||||
showTimeDivider: shouldShowTimeDivider(prevTs, aiNow),
|
||||
}
|
||||
|
||||
const messages = [...this.data.messages, aiMsg]
|
||||
this.setData({
|
||||
messages,
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
const aiIndex = messages.length - 1
|
||||
simulateStreamOutput(replyText, (partial: string) => {
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: partial,
|
||||
streamingContent: partial,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
}).then(() => {
|
||||
this.setData({
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/** 滚动到底部 */
|
||||
scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollToId: '' })
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollToId: 'scroll-bottom' })
|
||||
}, 50)
|
||||
}, 50)
|
||||
},
|
||||
})
|
||||
165
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxml
Normal file
165
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxml
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- pages/chat/chat.wxml — AI 对话页 -->
|
||||
<wxs src="../../utils/time.wxs" module="timefmt" />
|
||||
|
||||
<!-- 加载态 -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<view class="error-content">
|
||||
<text class="error-text">加载失败,请重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text class="retry-btn-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<view class="chat-page" wx:elif="{{pageState === 'normal' || pageState === 'empty'}}">
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
class="message-list"
|
||||
scroll-y
|
||||
scroll-into-view="{{scrollToId}}"
|
||||
scroll-with-animation
|
||||
enhanced
|
||||
show-scrollbar="{{false}}"
|
||||
style="bottom: {{inputBarBottom}}px;"
|
||||
>
|
||||
|
||||
<!-- 引用内容卡片(从其他页面跳转时显示)-->
|
||||
<view class="reference-card" wx:if="{{referenceCard}}">
|
||||
<view class="reference-label-row">
|
||||
<text class="reference-tag">引用内容</text>
|
||||
<text class="reference-source">{{referenceCard.title}}</text>
|
||||
</view>
|
||||
<text class="reference-summary">{{referenceCard.summary}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 空对话提示 -->
|
||||
<view class="empty-hint" wx:if="{{pageState === 'empty' && messages.length === 0}}">
|
||||
<view class="empty-ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="empty-ai-img" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="empty-text">你好,我是 AI 助手</text>
|
||||
<text class="empty-sub">有什么可以帮你的?</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡列表 -->
|
||||
<block wx:for="{{messages}}" wx:key="id">
|
||||
|
||||
<!--
|
||||
IM 时间分割线
|
||||
· 首条消息始终显示
|
||||
· 相邻消息间隔 ≥ 5 分钟时显示
|
||||
· 格式:今天 HH:mm / 今年 MM-DD HH:mm / 跨年 YYYY-MM-DD HH:mm
|
||||
-->
|
||||
<view class="time-divider" wx:if="{{item.showTimeDivider}}">
|
||||
<view class="time-divider-inner">
|
||||
<text class="time-divider-text">{{timefmt.imTime(item.timestamp)}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户消息:右对齐蓝色 -->
|
||||
<view class="message-row message-user" wx:if="{{item.role === 'user'}}" id="msg-{{item.id}}">
|
||||
<view class="user-bubble-col">
|
||||
<view class="bubble bubble-user">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
<!-- 用户侧引用卡片(用户发给 AI 的上下文卡片)-->
|
||||
<view class="inline-ref-card inline-ref-card--user" wx:if="{{item.referenceCard}}">
|
||||
<view class="inline-ref-header">
|
||||
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
|
||||
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
|
||||
</view>
|
||||
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
|
||||
<view class="inline-ref-data">
|
||||
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
|
||||
<text class="ref-data-key">{{entry.key}}</text>
|
||||
<text class="ref-data-value">{{entry.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 消息:左对齐白色 -->
|
||||
<view class="message-row message-assistant" wx:else id="msg-{{item.id}}">
|
||||
<view class="ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="bubble-col">
|
||||
<view class="bubble bubble-assistant">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
|
||||
<!-- AI 正在输入指示器 -->
|
||||
<view class="message-row message-assistant" wx:if="{{isStreaming && !streamingContent}}" id="msg-typing">
|
||||
<view class="ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="bubble bubble-assistant typing-bubble">
|
||||
<view class="typing-dots">
|
||||
<view class="dot dot-1"></view>
|
||||
<view class="dot dot-2"></view>
|
||||
<view class="dot dot-3"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view class="scroll-bottom-spacer" id="scroll-bottom"></view>
|
||||
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入区域 -->
|
||||
<view class="input-bar" style="bottom: {{inputBarBottom}}px;">
|
||||
<view class="input-wrapper">
|
||||
<input
|
||||
class="chat-input"
|
||||
value="{{inputText}}"
|
||||
placeholder="输入消息..."
|
||||
placeholder-class="input-placeholder"
|
||||
confirm-type="send"
|
||||
bindinput="onInputChange"
|
||||
bindconfirm="onSendMessage"
|
||||
bindfocus="onInputFocus"
|
||||
bindblur="onInputBlur"
|
||||
adjust-position="{{false}}"
|
||||
disabled="{{isStreaming}}"
|
||||
cursor-spacing="16"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="send-btn {{inputText.length > 0 && !isStreaming ? 'send-btn-active' : 'send-btn-disabled'}}"
|
||||
hover-class="send-btn--hover"
|
||||
bindtap="onSendMessage"
|
||||
>
|
||||
<image
|
||||
wx:if="{{inputText.length > 0 && !isStreaming}}"
|
||||
src="/assets/icons/send-arrow-white.svg"
|
||||
class="send-icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image
|
||||
wx:else
|
||||
src="/assets/icons/send-arrow-gray.svg"
|
||||
class="send-icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
372
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxss
Normal file
372
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxss
Normal file
@@ -0,0 +1,372 @@
|
||||
/* pages/chat/chat.wxss — AI 对话页样式 */
|
||||
|
||||
page {
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
/* ========== 错误态 ========== */
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
.error-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.error-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-8, #777777);
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background: var(--color-primary, #0052d9);
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
.retry-btn--hover { opacity: 0.8; }
|
||||
.retry-btn-text {
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ========== 页面容器 ========== */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ========== 消息列表 ========== */
|
||||
.message-list {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 112rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroll-bottom-spacer {
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
/* ========== 引用内容卡片 ========== */
|
||||
.reference-card {
|
||||
background: #ecf2fe;
|
||||
border-radius: 20rpx;
|
||||
border-left: 6rpx solid #0052d9;
|
||||
padding: 22rpx 26rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.reference-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.reference-quote-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reference-tag {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx;
|
||||
font-weight: 600;
|
||||
color: #0052d9;
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.reference-source {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
.reference-summary {
|
||||
font-size: 24rpx;
|
||||
line-height: 36rpx;
|
||||
color: #393939;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 空对话提示 ========== */
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 160rpx;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.empty-ai-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8rpx;
|
||||
box-shadow: 0 12rpx 32rpx rgba(102, 126, 234, 0.30);
|
||||
}
|
||||
.empty-ai-img {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-13, #242424);
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
}
|
||||
|
||||
/* ========== IM 时间分割线 ========== */
|
||||
.time-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20rpx 0 8rpx;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.time-divider-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rpx 20rpx;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 20rpx;
|
||||
margin: 12rpx;
|
||||
}
|
||||
|
||||
.time-divider-text {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-7, #8b8b8b);
|
||||
line-height: 28rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ========== IM 消息行 ========== */
|
||||
.message-row {
|
||||
display: flex;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.message-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.message-assistant {
|
||||
justify-content: flex-start;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* ========== AI 头像 ========== */
|
||||
.ai-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 6rpx 16rpx rgba(102, 126, 234, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-avatar-img {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
/* ========== 气泡 ========== */
|
||||
.bubble-wrapper {
|
||||
max-width: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.bubble {
|
||||
padding: 20rpx 28rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
.bubble-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.bubble-user {
|
||||
max-width: 80%;
|
||||
background-color: var(--color-primary, #0052d9);
|
||||
border-radius: 32rpx 8rpx 32rpx 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.22);
|
||||
}
|
||||
.bubble-user .bubble-text { color: #ffffff; }
|
||||
.bubble-assistant {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8rpx 32rpx 32rpx 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.bubble-assistant .bubble-text { color: var(--color-gray-13, #242424); }
|
||||
|
||||
/* ========== AI 内联引用卡片 ========== */
|
||||
.inline-ref-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-left: 6rpx solid var(--color-primary, #0052d9);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* 用户侧引用卡片:右对齐,挂在用户气泡下方 */
|
||||
.user-bubble-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12rpx;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.inline-ref-card--user {
|
||||
border-left: none;
|
||||
border-right: 6rpx solid var(--color-primary, #0052d9);
|
||||
background-color: #ecf2fe;
|
||||
max-width: 100%;
|
||||
}
|
||||
.inline-ref-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.inline-ref-type {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-primary, #0052d9);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inline-ref-title {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
font-weight: 500;
|
||||
}
|
||||
.inline-ref-summary {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-gray-8, #777777);
|
||||
margin-bottom: 12rpx;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.inline-ref-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx 24rpx;
|
||||
}
|
||||
.ref-data-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.ref-data-key {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-7, #8b8b8b);
|
||||
}
|
||||
.ref-data-value {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 打字指示器 ========== */
|
||||
.typing-bubble { padding: 20rpx 32rpx; }
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-gray-6, #a6a6a6);
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
.dot-1 { animation-delay: 0s; }
|
||||
.dot-2 { animation-delay: 0.2s; }
|
||||
.dot-3 { animation-delay: 0.4s; }
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ========== 底部输入区域 ========== */
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx 32rpx;
|
||||
background-color: #ffffff;
|
||||
border-top: 2rpx solid var(--color-gray-2, #eeeeee);
|
||||
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
z-index: 100;
|
||||
transition: bottom 0.25s ease;
|
||||
}
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
border-radius: 48rpx;
|
||||
padding: 18rpx 28rpx;
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
line-height: 1.4;
|
||||
background: transparent;
|
||||
}
|
||||
.input-placeholder {
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.send-btn {
|
||||
width: 88rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.send-btn-active {
|
||||
background: var(--color-primary, #0052d9);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.28);
|
||||
}
|
||||
.send-btn-disabled {
|
||||
background-color: var(--color-gray-3, #e0e0e0);
|
||||
}
|
||||
.send-btn--hover {
|
||||
opacity: 0.75;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.send-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"navigationBarTitleText": "助教详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
|
||||
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
|
||||
"note-modal": "/components/note-modal/note-modal",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import { mockCoaches } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
|
||||
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
|
||||
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
|
||||
*/
|
||||
const SHINE_SPEED = 70
|
||||
const SPARK_DELAY_MS = -150
|
||||
const SPARK_DUR_MS = 1400
|
||||
const NEXT_LOOP_DELAY_MS = 400
|
||||
const SHINE_WIDTH_RPX = 120
|
||||
const TRACK_WIDTH_RPX = 634
|
||||
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
|
||||
|
||||
function calcShineDur(filledPct: number): number {
|
||||
const t = (SHINE_SPEED - 1) / 99
|
||||
const baseDur = 5000 - t * (5000 - 50)
|
||||
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
return Math.max(50, Math.round(baseDur * distRatio))
|
||||
}
|
||||
|
||||
interface TickItem {
|
||||
value: number
|
||||
label: string
|
||||
left: string
|
||||
highlight: boolean
|
||||
}
|
||||
|
||||
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
|
||||
return tierNodes.map((v, i) => ({
|
||||
value: v,
|
||||
label: String(v),
|
||||
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
|
||||
highlight: i === 2,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 助教详情(含绩效、收入、任务、客户关系等) */
|
||||
interface CoachDetail {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
level: string
|
||||
skills: string[]
|
||||
workYears: number
|
||||
customerCount: number
|
||||
hireDate: string
|
||||
performance: {
|
||||
monthlyHours: number
|
||||
monthlySalary: number
|
||||
customerBalance: number
|
||||
tasksCompleted: number
|
||||
/** 绩效档位 */
|
||||
perfCurrent: number
|
||||
perfTarget: number
|
||||
}
|
||||
income: {
|
||||
thisMonth: IncomeItem[]
|
||||
lastMonth: IncomeItem[]
|
||||
}
|
||||
notes: NoteItem[]
|
||||
}
|
||||
|
||||
interface IncomeItem {
|
||||
label: string
|
||||
amount: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface NoteItem {
|
||||
id: string
|
||||
content: string
|
||||
timestamp: string
|
||||
score: number
|
||||
customerName: string
|
||||
tagLabel: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
typeLabel: string
|
||||
typeClass: string
|
||||
customerName: string
|
||||
noteCount: number
|
||||
pinned: boolean
|
||||
notes?: Array<{ pinned?: boolean; text: string; date: string }>
|
||||
}
|
||||
|
||||
interface AbandonedTask {
|
||||
customerName: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface TopCustomer {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
avatarGradient: string
|
||||
heartEmoji: string
|
||||
score: string
|
||||
scoreColor: string
|
||||
serviceCount: number
|
||||
balance: string
|
||||
consume: string
|
||||
}
|
||||
|
||||
interface ServiceRecord {
|
||||
customerId?: string
|
||||
customerName: string
|
||||
initial: string
|
||||
avatarGradient: string
|
||||
type: string
|
||||
typeClass: string
|
||||
table: string
|
||||
duration: string
|
||||
income: string
|
||||
date: string
|
||||
perfHours?: string
|
||||
}
|
||||
|
||||
interface HistoryMonth {
|
||||
month: string
|
||||
estimated: boolean
|
||||
customers: string
|
||||
hours: string
|
||||
salary: string
|
||||
callbackDone: number
|
||||
recallDone: number
|
||||
}
|
||||
|
||||
/** Mock 数据 */
|
||||
const mockCoachDetail: CoachDetail = {
|
||||
id: 'coach-001',
|
||||
name: '小燕',
|
||||
avatar: '/assets/images/avatar-coach.png',
|
||||
level: '星级',
|
||||
skills: ['中🎱', '🎯斯诺克'],
|
||||
workYears: 3,
|
||||
customerCount: 68,
|
||||
hireDate: '2023-03-15',
|
||||
performance: {
|
||||
monthlyHours: 87.5,
|
||||
monthlySalary: 6950,
|
||||
customerBalance: 86200,
|
||||
tasksCompleted: 38,
|
||||
perfCurrent: 80,
|
||||
perfTarget: 100,
|
||||
},
|
||||
income: {
|
||||
thisMonth: [
|
||||
{ label: '基础课时费', amount: '¥3,500', color: 'primary' },
|
||||
{ label: '激励课时费', amount: '¥1,800', color: 'success' },
|
||||
{ label: '充值提成', amount: '¥1,200', color: 'warning' },
|
||||
{ label: '酒水提成', amount: '¥450', color: 'purple' },
|
||||
],
|
||||
lastMonth: [
|
||||
{ label: '基础课时费', amount: '¥3,800', color: 'primary' },
|
||||
{ label: '激励课时费', amount: '¥1,900', color: 'success' },
|
||||
{ label: '充值提成', amount: '¥1,100', color: 'warning' },
|
||||
{ label: '酒水提成', amount: '¥400', color: 'purple' },
|
||||
],
|
||||
},
|
||||
notes: [
|
||||
{ id: 'n1', content: '本月表现优秀,客户好评率高', timestamp: '2026-03-05T14:30:00', score: 9, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-03-05 14:30' },
|
||||
{ id: 'n2', content: '需要加强斯诺克教学技巧', timestamp: '2026-02-28T10:00:00', score: 7, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-02-28 10:00' },
|
||||
{ id: 'n3', content: '客户王先生反馈服务态度很好', timestamp: '2026-02-20T16:45:00', score: 8, customerName: '王先生', tagLabel: '王先生', createdAt: '2026-02-20 16:45' },
|
||||
],
|
||||
}
|
||||
|
||||
const mockVisibleTasks: TaskItem[] = [
|
||||
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '王先生', noteCount: 2, pinned: true, notes: [{ pinned: true, text: '重点客户,每周必须联系', date: '2026-02-06' }, { text: '上次来说最近出差多', date: '2026-02-01' }] },
|
||||
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '李女士', noteCount: 0, pinned: true },
|
||||
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '陈女士', noteCount: 1, pinned: true, notes: [{ text: '喜欢斯诺克,周末常来', date: '2026-01-28' }] },
|
||||
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '张先生', noteCount: 0, pinned: false },
|
||||
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '赵总', noteCount: 3, pinned: false, notes: [{ pinned: true, text: '大客户,注意维护关系', date: '2026-02-03' }, { text: '上次带了3个朋友来', date: '2026-01-25' }, { text: '喜欢VIP包厢', date: '2026-01-15' }] },
|
||||
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '周女士', noteCount: 0, pinned: false },
|
||||
]
|
||||
|
||||
const mockHiddenTasks: TaskItem[] = [
|
||||
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '刘先生', noteCount: 0, pinned: false },
|
||||
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '孙先生', noteCount: 0, pinned: false },
|
||||
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '吴女士', noteCount: 0, pinned: false },
|
||||
]
|
||||
|
||||
const mockAbandonedTasks: AbandonedTask[] = [
|
||||
{ customerName: '吴先生', reason: '客户拒绝' },
|
||||
{ customerName: '郑女士', reason: '超时未响应' },
|
||||
]
|
||||
|
||||
const mockTopCustomers: TopCustomer[] = [
|
||||
{ id: 'c1', name: '王先生', initial: '王', avatarGradient: 'pink', heartEmoji: '❤️', score: '9.5', scoreColor: 'success', serviceCount: 25, balance: '¥8,600', consume: '¥12,800' },
|
||||
{ id: 'c2', name: '李女士', initial: '李', avatarGradient: 'amber', heartEmoji: '❤️', score: '9.2', scoreColor: 'success', serviceCount: 22, balance: '¥6,200', consume: '¥9,500' },
|
||||
{ id: 'c3', name: '陈女士', initial: '陈', avatarGradient: 'green', heartEmoji: '❤️', score: '8.5', scoreColor: 'warning', serviceCount: 18, balance: '¥5,000', consume: '¥7,200' },
|
||||
{ id: 'c4', name: '张先生', initial: '张', avatarGradient: 'blue', heartEmoji: '💛', score: '7.8', scoreColor: 'warning', serviceCount: 12, balance: '¥3,800', consume: '¥5,600' },
|
||||
{ id: 'c5', name: '赵先生', initial: '赵', avatarGradient: 'violet', heartEmoji: '💛', score: '6.8', scoreColor: 'gray', serviceCount: 8, balance: '¥2,000', consume: '¥3,200' },
|
||||
{ id: 'c6', name: '刘女士', initial: '刘', avatarGradient: 'pink', heartEmoji: '💛', score: '6.5', scoreColor: 'gray', serviceCount: 7, balance: '¥1,800', consume: '¥2,900' },
|
||||
{ id: 'c7', name: '孙先生', initial: '孙', avatarGradient: 'teal', heartEmoji: '💛', score: '6.2', scoreColor: 'gray', serviceCount: 6, balance: '¥1,500', consume: '¥2,500' },
|
||||
{ id: 'c8', name: '周女士', initial: '周', avatarGradient: 'amber', heartEmoji: '💛', score: '6.0', scoreColor: 'gray', serviceCount: 6, balance: '¥1,400', consume: '¥2,200' },
|
||||
{ id: 'c9', name: '吴先生', initial: '吴', avatarGradient: 'blue', heartEmoji: '💛', score: '5.8', scoreColor: 'gray', serviceCount: 5, balance: '¥1,200', consume: '¥2,000' },
|
||||
{ id: 'c10', name: '郑女士', initial: '郑', avatarGradient: 'green', heartEmoji: '💛', score: '5.5', scoreColor: 'gray', serviceCount: 5, balance: '¥1,000', consume: '¥1,800' },
|
||||
{ id: 'c11', name: '冯先生', initial: '冯', avatarGradient: 'violet', heartEmoji: '🤍', score: '5.2', scoreColor: 'gray', serviceCount: 4, balance: '¥900', consume: '¥1,600' },
|
||||
{ id: 'c12', name: '褚女士', initial: '褚', avatarGradient: 'pink', heartEmoji: '🤍', score: '5.0', scoreColor: 'gray', serviceCount: 4, balance: '¥800', consume: '¥1,400' },
|
||||
{ id: 'c13', name: '卫先生', initial: '卫', avatarGradient: 'amber', heartEmoji: '🤍', score: '4.8', scoreColor: 'gray', serviceCount: 3, balance: '¥700', consume: '¥1,200' },
|
||||
{ id: 'c14', name: '蒋女士', initial: '蒋', avatarGradient: 'teal', heartEmoji: '🤍', score: '4.5', scoreColor: 'gray', serviceCount: 3, balance: '¥600', consume: '¥1,000' },
|
||||
{ id: 'c15', name: '沈先生', initial: '沈', avatarGradient: 'blue', heartEmoji: '🤍', score: '4.2', scoreColor: 'gray', serviceCount: 3, balance: '¥500', consume: '¥900' },
|
||||
{ id: 'c16', name: '韩女士', initial: '韩', avatarGradient: 'green', heartEmoji: '🤍', score: '4.0', scoreColor: 'gray', serviceCount: 2, balance: '¥400', consume: '¥800' },
|
||||
{ id: 'c17', name: '杨先生', initial: '杨', avatarGradient: 'violet', heartEmoji: '🤍', score: '3.8', scoreColor: 'gray', serviceCount: 2, balance: '¥300', consume: '¥700' },
|
||||
{ id: 'c18', name: '朱女士', initial: '朱', avatarGradient: 'pink', heartEmoji: '🤍', score: '3.5', scoreColor: 'gray', serviceCount: 2, balance: '¥200', consume: '¥600' },
|
||||
{ id: 'c19', name: '秦先生', initial: '秦', avatarGradient: 'amber', heartEmoji: '🤍', score: '3.2', scoreColor: 'gray', serviceCount: 1, balance: '¥100', consume: '¥500' },
|
||||
{ id: 'c20', name: '尤女士', initial: '尤', avatarGradient: 'teal', heartEmoji: '🤍', score: '3.0', scoreColor: 'gray', serviceCount: 1, balance: '¥0', consume: '¥400' },
|
||||
]
|
||||
|
||||
const mockServiceRecords: ServiceRecord[] = [
|
||||
{ customerId: 'c1', customerName: '王先生', initial: '王', avatarGradient: 'pink', type: '基础课', typeClass: 'basic', table: 'A12号台', duration: '2.5h', income: '¥200', date: '2026-02-07 21:30' },
|
||||
{ customerId: 'c2', customerName: '李女士', initial: '李', avatarGradient: 'amber', type: '激励课', typeClass: 'incentive', table: 'VIP1号房', duration: '1.5h', income: '¥150', date: '2026-02-07 19:00', perfHours: '2h' },
|
||||
{ customerId: 'c3', customerName: '陈女士', initial: '陈', avatarGradient: 'green', type: '基础课', typeClass: 'basic', table: '2号台', duration: '2h', income: '¥160', date: '2026-02-06 20:00' },
|
||||
{ customerId: 'c4', customerName: '张先生', initial: '张', avatarGradient: 'blue', type: '激励课', typeClass: 'incentive', table: '5号台', duration: '1h', income: '¥80', date: '2026-02-05 14:00' },
|
||||
]
|
||||
|
||||
const mockHistoryMonths: HistoryMonth[] = [
|
||||
{ month: '本月', estimated: true, customers: '22人', hours: '87.5h', salary: '¥6,950', callbackDone: 14, recallDone: 24 },
|
||||
{ month: '上月', estimated: false, customers: '25人', hours: '92.0h', salary: '¥7,200', callbackDone: 16, recallDone: 28 },
|
||||
{ month: '4月', estimated: false, customers: '20人', hours: '85.0h', salary: '¥6,600', callbackDone: 12, recallDone: 22 },
|
||||
{ month: '3月', estimated: false, customers: '18人', hours: '78.5h', salary: '¥6,100', callbackDone: 10, recallDone: 18 },
|
||||
{ month: '2月', estimated: false, customers: '15人', hours: '65.0h', salary: '¥5,200', callbackDone: 8, recallDone: 15 },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
/** 页面状态:四态 */
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
/** 助教 ID */
|
||||
coachId: '',
|
||||
/** 助教详情 */
|
||||
detail: null as CoachDetail | null,
|
||||
/** 绩效指标卡片 */
|
||||
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
|
||||
/** 绩效进度 */
|
||||
perfCurrent: 0,
|
||||
perfTarget: 100,
|
||||
perfGap: 0,
|
||||
perfPercent: 0,
|
||||
/** 收入明细 Tab */
|
||||
incomeTab: 'this' as 'this' | 'last',
|
||||
currentIncome: [] as IncomeItem[],
|
||||
incomeTotal: '',
|
||||
/** 任务执行 */
|
||||
taskStats: { recall: 24, callback: 14 },
|
||||
visibleTasks: [] as TaskItem[],
|
||||
hiddenTasks: [] as TaskItem[],
|
||||
abandonedTasks: [] as AbandonedTask[],
|
||||
tasksExpanded: false,
|
||||
/** 客户关系 TOP20 */
|
||||
topCustomers: [] as TopCustomer[],
|
||||
topCustomersExpanded: false,
|
||||
/** 近期服务明细 */
|
||||
serviceRecords: [] as ServiceRecord[],
|
||||
/** 更多信息 */
|
||||
historyMonths: [] as HistoryMonth[],
|
||||
/** 备注 */
|
||||
sortedNotes: [] as NoteItem[],
|
||||
noteModalVisible: false,
|
||||
/** 备注列表弹窗 */
|
||||
notesPopupVisible: false,
|
||||
notesPopupName: '',
|
||||
notesPopupList: [] as Array<{ pinned?: boolean; text: string; date: string }>,
|
||||
/** 进度条动画状态(驱动 perf-progress-bar 组件) */
|
||||
pbFilledPct: 0,
|
||||
pbClampedSparkPct: 0,
|
||||
pbCurrentTier: 0,
|
||||
pbTicks: [] as TickItem[],
|
||||
pbShineRunning: false,
|
||||
pbSparkRunning: false,
|
||||
pbShineDurMs: 1000,
|
||||
pbSparkDurMs: SPARK_DUR_MS,
|
||||
},
|
||||
|
||||
_longPressed: false,
|
||||
_animTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
|
||||
onLoad(options: { id?: string }) {
|
||||
const id = options?.id || ''
|
||||
this.setData({ coachId: id })
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this._stopAnimLoop()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._stopAnimLoop()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this.data.pageState === 'normal' && !this._animTimer) {
|
||||
this._startAnimLoop()
|
||||
}
|
||||
},
|
||||
|
||||
_startAnimLoop() {
|
||||
this._stopAnimLoop()
|
||||
this._runAnimStep()
|
||||
},
|
||||
|
||||
_stopAnimLoop() {
|
||||
if (this._animTimer !== null) {
|
||||
clearTimeout(this._animTimer)
|
||||
this._animTimer = null
|
||||
}
|
||||
this.setData({ pbShineRunning: false, pbSparkRunning: false })
|
||||
},
|
||||
|
||||
_runAnimStep() {
|
||||
const filledPct = this.data.pbFilledPct ?? 0
|
||||
const shineDurMs = calcShineDur(filledPct)
|
||||
this.setData({ pbShineRunning: true, pbSparkRunning: false, pbShineDurMs: shineDurMs })
|
||||
|
||||
const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS)
|
||||
this._animTimer = setTimeout(() => {
|
||||
this.setData({ pbSparkRunning: true })
|
||||
this._animTimer = setTimeout(() => {
|
||||
this.setData({ pbShineRunning: false, pbSparkRunning: false })
|
||||
this._animTimer = setTimeout(() => {
|
||||
this._runAnimStep()
|
||||
}, Math.max(0, NEXT_LOOP_DELAY_MS))
|
||||
}, SPARK_DUR_MS)
|
||||
}, sparkTriggerDelay)
|
||||
},
|
||||
|
||||
loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// TODO: 替换为真实 API 调用 GET /api/coaches/:id
|
||||
const basicCoach = mockCoaches.find((c) => c.id === id)
|
||||
const detail: CoachDetail = basicCoach
|
||||
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
|
||||
: mockCoachDetail
|
||||
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
|
||||
const perf = detail.performance
|
||||
const perfCards = [
|
||||
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
|
||||
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
|
||||
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
|
||||
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
|
||||
]
|
||||
|
||||
const perfGap = perf.perfTarget - perf.perfCurrent
|
||||
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
|
||||
|
||||
// 进度条组件数据
|
||||
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock,实际由接口返回
|
||||
const maxHours = 220
|
||||
const totalHours = perf.monthlyHours
|
||||
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
|
||||
// 当前档位
|
||||
let pbCurrentTier = 0
|
||||
for (let i = 1; i < tierNodes.length; i++) {
|
||||
if (totalHours >= tierNodes[i]) pbCurrentTier = i
|
||||
else break
|
||||
}
|
||||
|
||||
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
|
||||
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
perfCards,
|
||||
perfCurrent: perf.perfCurrent,
|
||||
perfTarget: perf.perfTarget,
|
||||
perfGap,
|
||||
perfPercent,
|
||||
visibleTasks: mockVisibleTasks,
|
||||
hiddenTasks: mockHiddenTasks,
|
||||
abandonedTasks: mockAbandonedTasks,
|
||||
topCustomers: mockTopCustomers,
|
||||
serviceRecords: mockServiceRecords,
|
||||
historyMonths: mockHistoryMonths,
|
||||
sortedNotes: sorted,
|
||||
pbFilledPct,
|
||||
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
|
||||
pbCurrentTier,
|
||||
pbTicks: buildTicks(tierNodes, maxHours),
|
||||
pbShineDurMs: calcShineDur(pbFilledPct),
|
||||
pbSparkDurMs: SPARK_DUR_MS,
|
||||
})
|
||||
|
||||
this.switchIncomeTab('this')
|
||||
// 数据加载完成后启动动画循环
|
||||
setTimeout(() => this._startAnimLoop(), 300)
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 切换收入明细 Tab */
|
||||
switchIncomeTab(tab: 'this' | 'last') {
|
||||
const detail = this.data.detail
|
||||
if (!detail) return
|
||||
|
||||
const items = tab === 'this' ? detail.income.thisMonth : detail.income.lastMonth
|
||||
const total = items.reduce((sum, item) => {
|
||||
const num = parseFloat(item.amount.replace(/[¥,]/g, ''))
|
||||
return sum + (isNaN(num) ? 0 : num)
|
||||
}, 0)
|
||||
|
||||
this.setData({
|
||||
incomeTab: tab,
|
||||
currentIncome: items,
|
||||
incomeTotal: `¥${total.toLocaleString()}`,
|
||||
})
|
||||
},
|
||||
|
||||
/** 点击收入 Tab */
|
||||
onIncomeTabTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as 'this' | 'last'
|
||||
this.switchIncomeTab(tab)
|
||||
},
|
||||
|
||||
/** 展开/收起任务 */
|
||||
onToggleTasks() {
|
||||
this.setData({ tasksExpanded: !this.data.tasksExpanded })
|
||||
},
|
||||
|
||||
/** 点击任务项 — 跳转客户详情 */
|
||||
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const name = e.currentTarget.dataset.name as string
|
||||
if (!name) return
|
||||
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
|
||||
},
|
||||
|
||||
/** 展开/收起客户关系列表 */
|
||||
onToggleTopCustomers() {
|
||||
this.setData({ topCustomersExpanded: !this.data.topCustomersExpanded })
|
||||
},
|
||||
|
||||
/** 点击任务备注图标 — 弹出备注列表 */
|
||||
onTaskNoteTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const idx = e.currentTarget.dataset.index as number | undefined
|
||||
const hiddenIdx = e.currentTarget.dataset.hiddenIndex as number | undefined
|
||||
|
||||
let task: TaskItem | undefined
|
||||
if (idx !== undefined) {
|
||||
task = this.data.visibleTasks[idx]
|
||||
} else if (hiddenIdx !== undefined) {
|
||||
task = this.data.hiddenTasks[hiddenIdx]
|
||||
}
|
||||
|
||||
if (task?.notes && task.notes.length > 0) {
|
||||
this.setData({
|
||||
notesPopupVisible: true,
|
||||
notesPopupName: task.customerName,
|
||||
notesPopupList: task.notes,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 关闭备注列表弹窗 */
|
||||
onHideNotesPopup() {
|
||||
this.setData({ notesPopupVisible: false })
|
||||
},
|
||||
|
||||
/** 点击客户卡片 — 跳转客户详情 */
|
||||
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail?id=${id}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 近期服务明细 — 点击跳转客户详情 */
|
||||
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 查看更多服务记录 */
|
||||
onViewMoreRecords() {
|
||||
const coachId = this.data.coachId || this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/performance-records/performance-records?coachId=${coachId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 打开备注弹窗 */
|
||||
onAddNote() {
|
||||
this.setData({ noteModalVisible: true })
|
||||
},
|
||||
|
||||
/** 备注弹窗确认 */
|
||||
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
|
||||
const { content } = e.detail
|
||||
// TODO: 替换为真实 API 调用 POST /api/xcx/notes
|
||||
const newNote: NoteItem = {
|
||||
id: `n-${Date.now()}`,
|
||||
content,
|
||||
timestamp: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
score: 0,
|
||||
customerName: '我',
|
||||
tagLabel: '我',
|
||||
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
}
|
||||
const notes = [newNote, ...this.data.sortedNotes]
|
||||
this.setData({ noteModalVisible: false, sortedNotes: notes })
|
||||
wx.showToast({ title: '备注已保存', icon: 'success' })
|
||||
},
|
||||
|
||||
/** 备注弹窗取消 */
|
||||
onNoteCancel() {
|
||||
this.setData({ noteModalVisible: false })
|
||||
},
|
||||
|
||||
/** 重试 */
|
||||
onRetry() {
|
||||
const id = this.data.coachId || ''
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
/** 问问助手 */
|
||||
onStartChat() {
|
||||
const id = this.data.coachId || this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/chat/chat?coachId=${id}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
|
||||
<text class="empty-text">未找到助教信息</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败</text>
|
||||
<view class="retry-btn" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:elif="{{pageState === 'normal'}}">
|
||||
<!-- Banner 区域 -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-overlay">
|
||||
<view class="coach-header">
|
||||
<view class="avatar-box">
|
||||
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="info-middle">
|
||||
<view class="name-row">
|
||||
<text class="coach-name">{{detail.name}}</text>
|
||||
<coach-level-tag level="{{detail.level}}" />
|
||||
</view>
|
||||
<view class="skill-row">
|
||||
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-right-stats">
|
||||
<view class="right-stat">
|
||||
<text class="right-stat-label">工龄</text>
|
||||
<text class="right-stat-value">{{detail.workYears}}</text>
|
||||
<text class="right-stat-label">年</text>
|
||||
</view>
|
||||
<view class="right-stat">
|
||||
<text class="right-stat-label">客户</text>
|
||||
<text class="right-stat-value">{{detail.customerCount}}</text>
|
||||
<text class="right-stat-label">人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-content">
|
||||
|
||||
<!-- 绩效概览 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-blue">绩效概览</text>
|
||||
</view>
|
||||
<view class="perf-grid">
|
||||
<view class="perf-card {{item.bgClass}}" wx:for="{{perfCards}}" wx:key="label">
|
||||
<text class="perf-label">{{item.label}}</text>
|
||||
<view class="perf-value-row">
|
||||
<text class="perf-value {{item.valueColor}}">{{item.value}}</text>
|
||||
<text class="perf-unit" wx:if="{{item.unit}}">{{item.unit}}</text>
|
||||
</view>
|
||||
<text class="perf-sub">{{item.sub}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="perf-progress-box">
|
||||
<view class="perf-progress-header">
|
||||
<text class="perf-progress-label">绩效档位进度</text>
|
||||
<text class="perf-progress-hint">距下一档还差 {{perfGap}}h</text>
|
||||
</view>
|
||||
<perf-progress-bar
|
||||
filledPct="{{pbFilledPct}}"
|
||||
clampedSparkPct="{{pbClampedSparkPct}}"
|
||||
currentTier="{{pbCurrentTier}}"
|
||||
ticks="{{pbTicks}}"
|
||||
shineRunning="{{pbShineRunning}}"
|
||||
sparkRunning="{{pbSparkRunning}}"
|
||||
shineDurMs="{{pbShineDurMs}}"
|
||||
sparkDurMs="{{pbSparkDurMs}}"
|
||||
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入明细 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-green">收入明细</text>
|
||||
<view class="income-tabs">
|
||||
<view class="income-tab {{incomeTab === 'this' ? 'active' : ''}}" data-tab="this" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
|
||||
<text>本月</text>
|
||||
<text class="income-tab-est" wx:if="{{incomeTab === 'this'}}">预估</text>
|
||||
</view>
|
||||
<view class="income-tab {{incomeTab === 'last' ? 'active' : ''}}" data-tab="last" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
|
||||
<text>上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="income-list">
|
||||
<view class="income-item" wx:for="{{currentIncome}}" wx:key="label">
|
||||
<view class="income-dot dot-{{item.color}}"></view>
|
||||
<text class="income-label">{{item.label}}</text>
|
||||
<text class="income-amount">{{item.amount}}</text>
|
||||
</view>
|
||||
<view class="income-total">
|
||||
<text class="income-total-label">合计{{incomeTab === 'this' ? '(预估)' : ''}}</text>
|
||||
<text class="income-total-value">{{incomeTotal}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务执行 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">任务执行</text>
|
||||
<view class="task-summary">
|
||||
<text class="task-summary-label">本月完成</text>
|
||||
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}</text>个</text>
|
||||
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text>个</text>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-list">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
<t-icon name="chat" size="32rpx" color="#777777" />
|
||||
<text class="task-note-count">{{item.noteCount}}</text>
|
||||
</view>
|
||||
<text class="task-pin" wx:if="{{item.pinned}}">📌</text>
|
||||
</view>
|
||||
</view>
|
||||
<block wx:if="{{tasksExpanded}}">
|
||||
<view class="task-list task-list-extra">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
<t-icon name="chat" size="32rpx" color="#777777" />
|
||||
<text class="task-note-count">{{item.noteCount}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-item task-item-abandoned" wx:for="{{abandonedTasks}}" wx:key="index">
|
||||
<text class="task-abandoned-name">{{item.customerName}}</text>
|
||||
<text class="task-abandoned-reason">{{item.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="task-toggle" bindtap="onToggleTasks" hover-class="task-toggle--hover" wx:if="{{hiddenTasks.length > 0 || abandonedTasks.length > 0}}">
|
||||
<text>{{tasksExpanded ? '收起 ↑' : '展开全部 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户关系 TOP20 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-pink">客户关系 TOP20</text>
|
||||
<text class="header-hint">近60天</text>
|
||||
</view>
|
||||
<view class="top-customer-list">
|
||||
<view
|
||||
class="top-customer-item"
|
||||
hover-class="top-customer-item--hover"
|
||||
wx:for="{{topCustomers}}"
|
||||
wx:key="id"
|
||||
wx:if="{{topCustomersExpanded || index < 5}}"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onCustomerTap"
|
||||
>
|
||||
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
|
||||
<text class="top-customer-avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
<view class="top-customer-info">
|
||||
<view class="top-customer-name-row">
|
||||
<text class="top-customer-name">{{item.name}}</text>
|
||||
<text class="top-customer-heart">{{item.heartEmoji}}</text>
|
||||
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{item.score}}</text>
|
||||
</view>
|
||||
<view class="top-customer-stats">
|
||||
<text class="top-customer-stat">服务 <text class="top-customer-stat-val">{{item.serviceCount}}</text>次</text>
|
||||
<text class="top-customer-stat">储值 <text class="top-customer-stat-val">{{item.balance}}</text></text>
|
||||
<text class="top-customer-stat">消费 <text class="top-customer-stat-val">{{item.consume}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="toggle-btn" bindtap="onToggleTopCustomers" hover-class="toggle-btn--hover" wx:if="{{topCustomers.length > 5}}">
|
||||
<text>{{topCustomersExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近期服务明细 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-purple">近期服务明细</text>
|
||||
</view>
|
||||
<view class="svc-list">
|
||||
<view class="svc-card" wx:for="{{serviceRecords}}" wx:key="index"
|
||||
bindtap="onSvcCardTap" data-id="{{item.customerId}}" hover-class="svc-card--hover">
|
||||
<!-- 头像列 -->
|
||||
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
|
||||
<text class="top-customer-avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
<!-- 右侧内容列:两行垂直排列 -->
|
||||
<view class="svc-content">
|
||||
<!-- 第1行:客户名 + 类型标签 + 日期 -->
|
||||
<view class="svc-row1">
|
||||
<text class="svc-customer">{{item.customerName}}</text>
|
||||
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
|
||||
<text class="svc-date">{{item.date}}</text>
|
||||
</view>
|
||||
<!-- 第2行:台号 + 时长 + 绩效 + 收入 -->
|
||||
<view class="svc-row2">
|
||||
<view class="svc-row2-left">
|
||||
<text class="svc-table-tag">{{item.table}}</text>
|
||||
<text class="svc-duration">{{item.duration}}</text>
|
||||
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
|
||||
</view>
|
||||
<text class="svc-income">{{item.income}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="svc-more" bindtap="onViewMoreRecords" hover-class="svc-more--hover">
|
||||
<text>查看更多服务记录 →</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多信息 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-teal">更多信息</text>
|
||||
</view>
|
||||
<view class="more-info-row">
|
||||
<text class="more-info-label">入职日期</text>
|
||||
<text class="more-info-value">{{detail.hireDate}}</text>
|
||||
</view>
|
||||
<view class="history-table">
|
||||
<view class="history-thead">
|
||||
<text class="history-th history-th-left">月份</text>
|
||||
<text class="history-th">服务客户</text>
|
||||
<text class="history-th">访/召完成</text>
|
||||
<text class="history-th">业绩时长</text>
|
||||
<text class="history-th">工资</text>
|
||||
</view>
|
||||
<view class="history-row {{index === 0 ? 'history-row-current' : ''}}" wx:for="{{historyMonths}}" wx:key="month">
|
||||
<view class="history-td history-td-left">
|
||||
<text>{{item.month}}</text>
|
||||
<text class="history-est" wx:if="{{item.estimated}}">预估</text>
|
||||
</view>
|
||||
<text class="history-td">{{item.customers}}</text>
|
||||
<text class="history-td">{{item.callbackDone}} | {{item.recallDone}}</text>
|
||||
<text class="history-td {{index === 0 ? 'text-primary' : ''}}">{{item.hours}}</text>
|
||||
<text class="history-td {{index === 0 ? 'text-success' : ''}}">{{item.salary}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注列表 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">备注记录</text>
|
||||
<text class="header-hint">共 {{sortedNotes.length}} 条</text>
|
||||
</view>
|
||||
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
|
||||
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
|
||||
<view class="note-top">
|
||||
<text class="note-author">{{item.tagLabel}}</text>
|
||||
<text class="note-time">{{item.createdAt}}</text>
|
||||
</view>
|
||||
<text class="note-content">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="note-empty" wx:else>
|
||||
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
|
||||
<text class="empty-hint">暂无备注</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar safe-area-bottom">
|
||||
<view class="btn-chat" bindtap="onStartChat" hover-class="btn-chat--hover">
|
||||
<t-icon name="chat" size="36rpx" color="#ffffff" />
|
||||
<text>问问助手</text>
|
||||
</view>
|
||||
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
|
||||
<t-icon name="edit-1" size="36rpx" color="#242424" />
|
||||
<text>备注</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
|
||||
|
||||
<!-- 备注列表弹窗 -->
|
||||
<view class="notes-popup-overlay" wx:if="{{notesPopupVisible}}" catchtap="onHideNotesPopup">
|
||||
<view class="notes-popup" catchtap="">
|
||||
<view class="notes-popup-header">
|
||||
<text class="notes-popup-title">{{notesPopupName}} 的备注</text>
|
||||
<view class="notes-popup-close" bindtap="onHideNotesPopup" hover-class="notes-popup-close--hover">
|
||||
<t-icon name="close" size="40rpx" color="#8b8b8b" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="notes-popup-list">
|
||||
<view class="notes-popup-item" wx:for="{{notesPopupList}}" wx:key="index">
|
||||
<view class="notes-popup-item-top">
|
||||
<text wx:if="{{item.pinned}}">📌</text>
|
||||
<text class="notes-popup-item-date">{{item.date}}</text>
|
||||
</view>
|
||||
<text class="notes-popup-item-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"navigationBarTitleText": "客户详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"note-modal": "/components/note-modal/note-modal",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"clue-card": "/components/clue-card/clue-card",
|
||||
"ai-title-badge": "/components/ai-title-badge/ai-title-badge"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { mockCustomerDetail } from "../../utils/mock-data"
|
||||
|
||||
interface ConsumptionRecord {
|
||||
id: string
|
||||
type: "table" | "shop" | "recharge"
|
||||
date: string
|
||||
tableName?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
duration?: string
|
||||
tableFee?: number
|
||||
tableOrigPrice?: number
|
||||
coaches?: Array<{
|
||||
name: string
|
||||
level: string
|
||||
levelColor: string
|
||||
courseType: string
|
||||
hours: string
|
||||
perfHours?: string
|
||||
fee: number
|
||||
}>
|
||||
foodAmount?: number
|
||||
foodOrigPrice?: number
|
||||
totalAmount?: number
|
||||
totalOrigPrice?: number
|
||||
payMethod?: string
|
||||
rechargeAmount?: number
|
||||
}
|
||||
|
||||
const mockRecords: ConsumptionRecord[] = [
|
||||
{
|
||||
id: "r1",
|
||||
type: "table",
|
||||
date: "2026-02-05",
|
||||
tableName: "A12号台",
|
||||
startTime: "21:30",
|
||||
endTime: "00:50",
|
||||
duration: "3h 20min",
|
||||
tableFee: 180,
|
||||
tableOrigPrice: 240,
|
||||
coaches: [
|
||||
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "2.5h", fee: 200 },
|
||||
{ name: "Amy", level: "junior", levelColor: "green", courseType: "激励课", hours: "0.5h", perfHours: "1h", fee: 50 },
|
||||
],
|
||||
foodAmount: 210,
|
||||
foodOrigPrice: 260,
|
||||
totalAmount: 640,
|
||||
totalOrigPrice: 750,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
type: "table",
|
||||
date: "2026-02-01",
|
||||
tableName: "888号台",
|
||||
startTime: "14:00",
|
||||
endTime: "16:00",
|
||||
duration: "2h 00min",
|
||||
tableFee: 120,
|
||||
coaches: [
|
||||
{ name: "泡芙", level: "middle", levelColor: "purple", courseType: "激励课", hours: "1.5h", perfHours: "2h", fee: 100 },
|
||||
],
|
||||
totalAmount: 220,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
type: "shop",
|
||||
date: "2026-01-28",
|
||||
coaches: [
|
||||
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "1h", fee: 100 },
|
||||
],
|
||||
foodAmount: 180,
|
||||
totalAmount: 280,
|
||||
},
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: "loading" as "loading" | "empty" | "error" | "normal",
|
||||
detail: {
|
||||
id: "cust_001",
|
||||
name: "王先生",
|
||||
avatarChar: "王",
|
||||
phone: "13812345678",
|
||||
balance: "8,600",
|
||||
consumption60d: "2,800",
|
||||
idealInterval: "7天",
|
||||
daysSinceVisit: "12天",
|
||||
},
|
||||
phoneVisible: false,
|
||||
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
|
||||
aiInsight: {
|
||||
summary: "高价值 VIP 客户,月均到店 4-5 次,偏好夜场中式台球,近期对斯诺克产生兴趣。社交属性强,常带固定球搭子,有拉新能力。储值余额充足,对促销活动响应积极。",
|
||||
strategies: [
|
||||
{ color: "green", text: "最后到店距今 12 天,超出理想间隔 7 天,建议尽快安排助教小燕主动联系召回" },
|
||||
{ color: "amber", text: "客户提到想练斯诺克走位,可推荐斯诺克专项课程包,结合储值优惠提升客单价" },
|
||||
{ color: "pink", text: "社交属性强,可邀请参加门店球友赛事活动,带动球搭子到店消费" },
|
||||
],
|
||||
},
|
||||
clues: [
|
||||
{
|
||||
category: "客户\n基础",
|
||||
categoryColor: "primary",
|
||||
text: "🎂 生日 3月15日 · VIP会员 · 注册2年",
|
||||
source: "系统",
|
||||
},
|
||||
{
|
||||
category: "消费\n习惯",
|
||||
categoryColor: "success",
|
||||
text: "🌙 常来夜场 · 月均4-5次",
|
||||
source: "系统",
|
||||
},
|
||||
{
|
||||
category: "消费\n习惯",
|
||||
categoryColor: "success",
|
||||
text: "💰 高客单价",
|
||||
source: "系统",
|
||||
detail: "近60天场均消费 ¥420,高于门店均值 ¥180;偏好夜场时段,酒水附加消费占比 35%",
|
||||
},
|
||||
{
|
||||
category: "玩法\n偏好",
|
||||
categoryColor: "purple",
|
||||
text: "🎱 偏爱中式 · 斯诺克进阶中",
|
||||
source: "系统",
|
||||
},
|
||||
{
|
||||
category: "促销\n接受",
|
||||
categoryColor: "warning",
|
||||
text: "🍷 爱点酒水套餐 · 对储值活动敏感",
|
||||
source: "系统",
|
||||
detail: "最近3次到店均点了酒水套餐;上次 ¥5000 储值活动当天即充值,对满赠类活动响应率高",
|
||||
},
|
||||
{
|
||||
category: "社交\n关系",
|
||||
categoryColor: "pink",
|
||||
text: "👥 常带朋友 · 固定球搭子2人",
|
||||
source: "系统",
|
||||
detail: "近60天 80% 的到店为多人局,常与「李哥」「阿杰」同行;曾介绍2位新客办卡",
|
||||
},
|
||||
{
|
||||
category: "重要\n反馈",
|
||||
categoryColor: "error",
|
||||
text: "⚠️ 上次提到想练斯诺克走位,对球桌维护质量比较在意,建议优先安排VIP房",
|
||||
source: "小燕",
|
||||
},
|
||||
],
|
||||
coachTasks: [
|
||||
{
|
||||
name: "小燕",
|
||||
level: "senior",
|
||||
levelColor: "pink",
|
||||
taskType: "高优先召回",
|
||||
taskColor: "red",
|
||||
bgClass: "coach-card-red",
|
||||
status: "normal",
|
||||
lastService: "02-20 21:30 · 2.5h",
|
||||
metrics: [
|
||||
{ label: "近60天次数", value: "18次", color: "primary" },
|
||||
{ label: "总时长", value: "17h" },
|
||||
{ label: "次均时长", value: "0.9h", color: "warning" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "泡芙",
|
||||
level: "middle",
|
||||
levelColor: "purple",
|
||||
taskType: "优先召回",
|
||||
taskColor: "orange",
|
||||
bgClass: "coach-card-orange",
|
||||
status: "pinned",
|
||||
lastService: "02-15 14:00 · 1.5h",
|
||||
metrics: [
|
||||
{ label: "近60天次数", value: "12次", color: "primary" },
|
||||
{ label: "总时长", value: "11h" },
|
||||
{ label: "次均时长", value: "0.9h", color: "warning" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Amy",
|
||||
level: "junior",
|
||||
levelColor: "green",
|
||||
taskType: "关系构建",
|
||||
taskColor: "pink",
|
||||
bgClass: "coach-card-pink",
|
||||
status: "normal",
|
||||
lastService: "02-10 19:00 · 1.0h",
|
||||
metrics: [
|
||||
{ label: "近60天次数", value: "8次", color: "primary" },
|
||||
{ label: "总时长", value: "6h" },
|
||||
{ label: "次均时长", value: "0.75h", color: "warning" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Lucy",
|
||||
level: "senior",
|
||||
levelColor: "pink",
|
||||
taskType: "客户回访",
|
||||
taskColor: "teal",
|
||||
bgClass: "coach-card-teal",
|
||||
status: "abandoned",
|
||||
lastService: "01-28 20:30 · 2.0h",
|
||||
metrics: [
|
||||
{ label: "近60天次数", value: "6次", color: "primary" },
|
||||
{ label: "总时长", value: "9h" },
|
||||
{ label: "次均时长", value: "1.5h", color: "warning" },
|
||||
],
|
||||
},
|
||||
],
|
||||
favoriteCoaches: [
|
||||
{
|
||||
emoji: "❤️",
|
||||
name: "小燕",
|
||||
relationIndex: "9.2",
|
||||
indexColor: "success",
|
||||
bgClass: "fav-card-pink",
|
||||
stats: [
|
||||
{ label: "基础", value: "12h", color: "primary" },
|
||||
{ label: "激励", value: "5h", color: "warning" },
|
||||
{ label: "上课", value: "18次" },
|
||||
{ label: "充值", value: "¥5,000", color: "success" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💛",
|
||||
name: "泡芙",
|
||||
relationIndex: "7.8",
|
||||
indexColor: "warning",
|
||||
bgClass: "fav-card-amber",
|
||||
stats: [
|
||||
{ label: "基础", value: "8h", color: "primary" },
|
||||
{ label: "激励", value: "3h", color: "warning" },
|
||||
{ label: "上课", value: "12次" },
|
||||
{ label: "充值", value: "¥3,000", color: "success" },
|
||||
],
|
||||
},
|
||||
],
|
||||
consumptionRecords: mockRecords,
|
||||
loadingMore: false,
|
||||
noteModalVisible: false,
|
||||
sortedNotes: [
|
||||
{ id: 'n1', tagLabel: '管理员', createdAt: '2026-03-05 14:30', content: '本月到店积极,对斯诺克课程感兴趣,建议持续跟进推荐相关课程包' },
|
||||
{ id: 'n2', tagLabel: '小燕', createdAt: '2026-02-20 16:45', content: '客户反馈服务态度很好,提到下次想带朋友一起来' },
|
||||
{ id: 'n3', tagLabel: '管理员', createdAt: '2026-02-10 10:00', content: '上次储值活动当天即充值 ¥5000,对满赠类活动响应积极' },
|
||||
] as Array<{ id: string; tagLabel: string; createdAt: string; content: string }>,
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
// 随机 AI 配色
|
||||
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
|
||||
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
|
||||
this.setData({ aiColor })
|
||||
this.loadDetail()
|
||||
},
|
||||
|
||||
loadDetail() {
|
||||
this.setData({ pageState: "normal" })
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadDetail()
|
||||
},
|
||||
|
||||
/** 查看/隐藏手机号 */
|
||||
onTogglePhone() {
|
||||
this.setData({ phoneVisible: !this.data.phoneVisible })
|
||||
},
|
||||
|
||||
/** 复制手机号 */
|
||||
onCopyPhone() {
|
||||
const phone = this.data.detail.phone
|
||||
wx.setClipboardData({
|
||||
data: phone,
|
||||
success: () => {
|
||||
wx.showToast({ title: '手机号码已复制', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onViewServiceRecords() {
|
||||
wx.navigateTo({ url: "/pages/customer-service-records/customer-service-records" })
|
||||
},
|
||||
|
||||
onStartChat() {
|
||||
wx.navigateTo({ url: "/pages/chat/chat" })
|
||||
},
|
||||
|
||||
onAddNote() {
|
||||
this.setData({ noteModalVisible: true })
|
||||
},
|
||||
|
||||
onNoteConfirm(e: any) {
|
||||
this.setData({ noteModalVisible: false })
|
||||
},
|
||||
|
||||
onNoteCancel() {
|
||||
this.setData({ noteModalVisible: false })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
<!-- pages/customer-detail/customer-detail.wxml -->
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
|
||||
<text class="empty-text">未找到客户信息</text>
|
||||
</view>
|
||||
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败</text>
|
||||
<view class="retry-btn" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
|
||||
</view>
|
||||
|
||||
<block wx:elif="{{pageState === 'normal'}}">
|
||||
<!-- Banner 区域 — SVG 做渐变底图 -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-dark-gold-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-overlay">
|
||||
<!-- 客户头部信息 -->
|
||||
<view class="customer-header">
|
||||
<view class="avatar-box">
|
||||
<text class="avatar-text">{{detail.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="info-right">
|
||||
<view class="name-row">
|
||||
<text class="customer-name">{{detail.name}}</text>
|
||||
</view>
|
||||
<view class="sub-info">
|
||||
<text class="phone">{{phoneVisible ? detail.phone : '138****5678'}}</text>
|
||||
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
|
||||
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Banner 统计 -->
|
||||
<view class="banner-stats">
|
||||
<view class="stat-item stat-border">
|
||||
<text class="stat-value stat-green">¥{{detail.balance}}</text>
|
||||
<text class="stat-label">储值余额</text>
|
||||
</view>
|
||||
<view class="stat-item stat-border">
|
||||
<text class="stat-value">¥{{detail.consumption60d}}</text>
|
||||
<text class="stat-label">60天消费</text>
|
||||
</view>
|
||||
<view class="stat-item stat-border">
|
||||
<text class="stat-value">{{detail.idealInterval}}</text>
|
||||
<text class="stat-label">理想间隔</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-amber">{{detail.daysSinceVisit}}</text>
|
||||
<text class="stat-label">距今到店</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-content" >
|
||||
<!-- AI 智能洞察 -->
|
||||
<view class="ai-insight-card">
|
||||
<view class="ai-insight-header">
|
||||
<view class="ai-icon-box">
|
||||
<image class="ai-icon-img" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="ai-insight-label">AI 智能洞察</text>
|
||||
</view>
|
||||
<view class="ai-insight-summary-v">
|
||||
<text class="ai-insight-summary">{{aiInsight.summary}}</text>
|
||||
</view>
|
||||
<view class="ai-strategy-box">
|
||||
<text class="strategy-title">当前推荐策略</text>
|
||||
<view class="strategy-list">
|
||||
<view class="strategy-item strategy-item-{{item.color}}" wx:for="{{aiInsight.strategies}}" wx:key="index" wx:if="{{index < aiInsight.strategies.length - 1}}">
|
||||
<text class="strategy-text">{{item.text}}</text>
|
||||
</view>
|
||||
<view class="strategy-item strategy-item-{{item.color}} strategy-item-last" wx:for="{{aiInsight.strategies}}" wx:key="index" wx:if="{{index === aiInsight.strategies.length - 1}}">
|
||||
<text class="strategy-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维客线索 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-green">维客线索</text>
|
||||
<ai-title-badge color="{{aiColor}}" />
|
||||
</view>
|
||||
<view class="clue-list">
|
||||
<clue-card
|
||||
wx:for="{{clues}}"
|
||||
wx:key="index"
|
||||
tag="{{item.category}}"
|
||||
category="{{item.categoryColor}}"
|
||||
emoji=""
|
||||
title="{{item.text}}"
|
||||
source="By:{{item.source}}"
|
||||
content="{{item.detail}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 助教任务 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-blue">助教任务分配</text>
|
||||
<text class="header-hint">当前进行中</text>
|
||||
</view>
|
||||
<view class="coach-task-list">
|
||||
<view class="coach-task-card {{item.bgClass}}" wx:for="{{coachTasks}}" wx:key="index">
|
||||
<view class="coach-task-top">
|
||||
<view class="coach-name-row">
|
||||
<text class="coach-name">{{item.name}}</text>
|
||||
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
|
||||
</view>
|
||||
<view class="coach-task-right">
|
||||
<text class="coach-task-type type-{{item.taskColor}}">{{item.taskType}}</text>
|
||||
<text class="coach-task-status status-{{item.status}}" wx:if="{{item.status !== 'normal'}}">
|
||||
<text wx:if="{{item.status === 'pinned'}}">📌 置顶</text>
|
||||
<text wx:elif="{{item.status === 'abandoned'}}">❌ 已放弃</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="coach-last-service">上次服务:{{item.lastService}}</text>
|
||||
<view class="coach-metrics">
|
||||
<view class="coach-metric" wx:for="{{item.metrics}}" wx:for-item="m" wx:key="label">
|
||||
<text class="metric-label">{{m.label}}</text>
|
||||
<text class="metric-value {{m.color ? 'text-' + m.color : ''}}">{{m.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最喜欢的助教 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-pink">最喜欢的助教</text>
|
||||
<text class="header-hint">近60天</text>
|
||||
</view>
|
||||
<view class="fav-coach-list">
|
||||
<view class="fav-coach-card {{item.bgClass}}" wx:for="{{favoriteCoaches}}" wx:key="index">
|
||||
<view class="fav-coach-top">
|
||||
<view class="fav-coach-name">
|
||||
<text class="fav-emoji">{{item.emoji}}</text>
|
||||
<text class="fav-name">{{item.name}}</text>
|
||||
</view>
|
||||
<view class="fav-index">
|
||||
<text class="fav-index-label">关系指数</text>
|
||||
<text class="fav-index-value text-{{item.indexColor}}">{{item.relationIndex}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="fav-period">近60天</text>
|
||||
<view class="fav-stats">
|
||||
<view class="fav-stat" wx:for="{{item.stats}}" wx:for-item="s" wx:key="label">
|
||||
<text class="fav-stat-label">{{s.label}}</text>
|
||||
<text class="fav-stat-value {{s.color ? 'text-' + s.color : ''}}">{{s.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消费记录 -->
|
||||
<view class="card">
|
||||
<view class="card-header" bindtap="onViewServiceRecords" hover-class="card-header--hover">
|
||||
<text class="section-title title-orange">消费记录</text>
|
||||
<t-icon name="chevron-right" size="40rpx" color="#a6a6a6" />
|
||||
</view>
|
||||
|
||||
<view class="record-list" wx:if="{{consumptionRecords.length > 0}}">
|
||||
<block wx:for="{{consumptionRecords}}" wx:key="id">
|
||||
|
||||
<!-- 台桌结账 -->
|
||||
<view class="record-card" wx:if="{{item.type === 'table'}}">
|
||||
<view class="record-card-header record-header-blue">
|
||||
<view class="record-project">
|
||||
<view class="record-dot record-dot-blue"></view>
|
||||
<text class="record-project-name record-name-blue">{{item.tableName}}</text>
|
||||
</view>
|
||||
<text class="record-date">{{item.date}}</text>
|
||||
</view>
|
||||
<view class="record-time-row">
|
||||
<view class="record-time-left">
|
||||
<text class="record-time-text">{{item.startTime}}</text>
|
||||
<text class="record-time-arrow">→</text>
|
||||
<text class="record-time-text">{{item.endTime}}</text>
|
||||
<text class="record-duration-tag">{{item.duration}}</text>
|
||||
</view>
|
||||
<view class="record-fee-right">
|
||||
<text class="record-fee-amount">¥{{item.tableFee}}</text>
|
||||
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">¥{{item.tableOrigPrice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
|
||||
<view class="record-coach-grid">
|
||||
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
|
||||
<view class="record-coach-name-row">
|
||||
<text class="record-coach-name">{{c.name}}</text>
|
||||
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
|
||||
</view>
|
||||
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
|
||||
<view class="record-coach-bottom">
|
||||
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
|
||||
<text class="record-coach-fee">¥{{c.fee}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<view class="record-food-right">
|
||||
<text class="record-food-amount">¥{{item.foodAmount}}</text>
|
||||
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">¥{{item.foodOrigPrice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-total-row" wx:if="{{item.totalAmount}}">
|
||||
<text class="record-total-label">总金额</text>
|
||||
<view class="record-total-right">
|
||||
<text class="record-total-amount">¥{{item.totalAmount}}</text>
|
||||
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">¥{{item.totalOrigPrice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商城订单 -->
|
||||
<view class="record-card" wx:elif="{{item.type === 'shop'}}">
|
||||
<view class="record-card-header record-header-green">
|
||||
<view class="record-project">
|
||||
<view class="record-dot record-dot-green"></view>
|
||||
<text class="record-project-name record-name-green">商城订单</text>
|
||||
</view>
|
||||
<text class="record-date">{{item.date}}</text>
|
||||
</view>
|
||||
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
|
||||
<view class="record-coach-grid">
|
||||
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
|
||||
<view class="record-coach-name-row">
|
||||
<text class="record-coach-name">{{c.name}}</text>
|
||||
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
|
||||
</view>
|
||||
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
|
||||
<view class="record-coach-bottom">
|
||||
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
|
||||
<text class="record-coach-fee">¥{{c.fee}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<text class="record-food-amount">¥{{item.foodAmount}}</text>
|
||||
</view>
|
||||
<view class="record-total-row" wx:if="{{item.totalAmount}}">
|
||||
<text class="record-total-label">总金额</text>
|
||||
<text class="record-total-amount">¥{{item.totalAmount}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="record-loading-more" wx:if="{{loadingMore}}">
|
||||
<t-loading theme="circular" size="40rpx" text="加载更多..." />
|
||||
</view>
|
||||
|
||||
<view class="record-empty" wx:if="{{consumptionRecords.length === 0 && !loadingMore}}">
|
||||
<t-icon name="info-circle" size="80rpx" color="#dcdcdc" />
|
||||
<text class="empty-hint">暂无消费记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注记录 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">备注记录</text>
|
||||
<text class="header-hint">共 {{sortedNotes.length}} 条</text>
|
||||
</view>
|
||||
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
|
||||
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
|
||||
<view class="note-top">
|
||||
<text class="note-author">{{item.tagLabel}}</text>
|
||||
<text class="note-time">{{item.createdAt}}</text>
|
||||
</view>
|
||||
<text class="note-content">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="note-empty" wx:else>
|
||||
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
|
||||
<text class="empty-hint">暂无备注</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar safe-area-bottom">
|
||||
<view class="btn-chat" bindtap="onStartChat" hover-class="btn-chat--hover">
|
||||
<t-icon name="chat" size="36rpx" color="#ffffff" />
|
||||
<text>问问助手</text>
|
||||
</view>
|
||||
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
|
||||
<t-icon name="edit-1" size="36rpx" color="#242424" />
|
||||
<text>备注</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" showExpandBtn="{{false}}" showRating="{{false}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button customerId="{{detail.id}}" />
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationBarTitleText": "服务记录",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"service-record-card": "/components/service-record-card/service-record-card",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data'
|
||||
import type { ConsumptionRecord } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
|
||||
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
|
||||
interface ServiceRecord extends ConsumptionRecord {
|
||||
/** 台桌号,如 "A12号台" */
|
||||
table: string
|
||||
/** 课程类型标签,如 "基础课" */
|
||||
type: string
|
||||
/** 课程样式 class 后缀:basic / vip / tip / recharge */
|
||||
typeClass: 'basic' | 'vip' | 'tip' | 'recharge'
|
||||
/** 卡片类型:course=普通课,recharge=充值提成 */
|
||||
recordType: 'course' | 'recharge'
|
||||
/** 折算后小时数(原始数字,组件负责加 h 后缀) */
|
||||
duration: number
|
||||
/** 折算前小时数(原始数字,组件负责加 h 后缀) */
|
||||
durationRaw: number
|
||||
/** 到手金额(原始数字,组件负责加 ¥ 前缀) */
|
||||
income: number
|
||||
/** 是否预估金额 */
|
||||
isEstimate: boolean
|
||||
/** 商品/饮品描述 */
|
||||
drinks: string
|
||||
/** 显示用日期,如 "2月5日 15:00 - 17:00" */
|
||||
date: string
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
/** 页面状态 */
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
/** 客户 ID */
|
||||
customerId: '',
|
||||
/** 客户名 */
|
||||
customerName: '',
|
||||
/** 客户名首字 */
|
||||
customerInitial: '',
|
||||
/** 客户电话(脱敏) */
|
||||
customerPhone: '139****5678',
|
||||
/** 客户电话(完整,查看后显示) */
|
||||
customerPhoneFull: '13900005678',
|
||||
/** 手机号是否已展开 */
|
||||
phoneVisible: false,
|
||||
/** 累计服务次数 */
|
||||
totalServiceCount: 0,
|
||||
/** 关系指数 */
|
||||
relationIndex: '0.85',
|
||||
/** 当前月份标签 */
|
||||
monthLabel: '',
|
||||
/** 当前年 */
|
||||
currentYear: 2026,
|
||||
/** 当前月 */
|
||||
currentMonth: 2,
|
||||
/** 最小年月(数据起始) */
|
||||
minYearMonth: 202601,
|
||||
/** 最大年月(当前月) */
|
||||
maxYearMonth: 202602,
|
||||
/** 是否可切换上月 */
|
||||
canPrev: true,
|
||||
/** 是否可切换下月 */
|
||||
canNext: false,
|
||||
/** 月度统计 */
|
||||
monthCount: '6次',
|
||||
monthHours: '11.5h',
|
||||
monthRelation: '0.85',
|
||||
/** 当前月的服务记录 */
|
||||
records: [] as ServiceRecord[],
|
||||
/** 所有记录(原始) */
|
||||
allRecords: [] as ConsumptionRecord[],
|
||||
/** 是否还有更多 */
|
||||
hasMore: false,
|
||||
/** 加载更多中 */
|
||||
loadingMore: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const id = options?.customerId || options?.id || ''
|
||||
this.setData({ customerId: id })
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
/** 加载数据 */
|
||||
loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const customer = mockCustomers.find((c) => c.id === id)
|
||||
const detail = customer
|
||||
? { ...mockCustomerDetail, id: customer.id, name: customer.name }
|
||||
: mockCustomerDetail
|
||||
|
||||
const allRecords = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
|
||||
const name = detail.name || '客户'
|
||||
|
||||
this.setData({
|
||||
customerName: name,
|
||||
customerInitial: name[0] || '?',
|
||||
allRecords,
|
||||
totalServiceCount: allRecords.length,
|
||||
})
|
||||
|
||||
this.updateMonthView()
|
||||
}, 400)
|
||||
},
|
||||
|
||||
/** 根据当前月份筛选并更新视图 */
|
||||
updateMonthView() {
|
||||
const { currentYear, currentMonth, allRecords } = this.data
|
||||
const monthLabel = `${currentYear}年${currentMonth}月`
|
||||
|
||||
// 筛选当月记录
|
||||
const monthPrefix = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
|
||||
const monthRecords = allRecords.filter((r) => r.date.startsWith(monthPrefix))
|
||||
|
||||
// 转换为展示格式(对齐 task-detail ServiceRecord,复用 service-record-card 组件)
|
||||
// income / duration 均传原始数字,由组件统一加 ¥ 和 h
|
||||
const records: ServiceRecord[] = monthRecords.map((r) => {
|
||||
const d = new Date(r.date)
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const dateLabel = `${month}月${day}日`
|
||||
const timeRange = this.generateTimeRange(r.duration)
|
||||
const isRecharge = r.project.includes('充值')
|
||||
return {
|
||||
...r,
|
||||
table: this.getTableNo(r.id),
|
||||
type: this.getTypeLabel(r.project),
|
||||
typeClass: this.getTypeClass(r.project) as 'basic' | 'vip' | 'tip' | 'recharge',
|
||||
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
|
||||
duration: isRecharge ? 0 : parseFloat((r.duration / 60).toFixed(1)),
|
||||
durationRaw: 0,
|
||||
income: r.amount,
|
||||
isEstimate: false,
|
||||
drinks: '',
|
||||
date: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
|
||||
}
|
||||
})
|
||||
|
||||
// 月度统计
|
||||
const totalMinutes = monthRecords.reduce((sum, r) => sum + r.duration, 0)
|
||||
const monthCount = monthRecords.length + '次'
|
||||
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
|
||||
|
||||
// 边界判断
|
||||
const yearMonth = currentYear * 100 + currentMonth
|
||||
const canPrev = yearMonth > this.data.minYearMonth
|
||||
const canNext = yearMonth < this.data.maxYearMonth
|
||||
|
||||
const isEmpty = records.length === 0 && allRecords.length === 0
|
||||
|
||||
this.setData({
|
||||
monthLabel,
|
||||
records,
|
||||
monthCount,
|
||||
monthHours,
|
||||
canPrev,
|
||||
canNext,
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
})
|
||||
},
|
||||
|
||||
/** 生成模拟时间段 */
|
||||
generateTimeRange(durationMin: number): string {
|
||||
const startHour = 14 + Math.floor(Math.random() * 6)
|
||||
const endMin = startHour * 60 + durationMin
|
||||
const endHour = Math.floor(endMin / 60)
|
||||
const endMinute = endMin % 60
|
||||
return `${startHour}:00 - ${endHour}:${String(endMinute).padStart(2, '0')}`
|
||||
},
|
||||
|
||||
/** 课程类型标签 */
|
||||
getTypeLabel(project: string): string {
|
||||
if (project.includes('小组')) return '小组课'
|
||||
if (project.includes('1v1')) return '基础课'
|
||||
if (project.includes('充值')) return '充值'
|
||||
if (project.includes('斯诺克')) return '斯诺克'
|
||||
return '基础课'
|
||||
},
|
||||
|
||||
/** 课程类型样式(对齐 service-record-card typeClass prop)*/
|
||||
getTypeClass(project: string): string {
|
||||
if (project.includes('充值')) return 'recharge'
|
||||
if (project.includes('小组')) return 'vip'
|
||||
if (project.includes('斯诺克')) return 'vip'
|
||||
return 'basic'
|
||||
},
|
||||
|
||||
/** 模拟台号 */
|
||||
getTableNo(id: string): string {
|
||||
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
|
||||
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
|
||||
return tables[idx]
|
||||
},
|
||||
|
||||
/** 切换到上一月 */
|
||||
onPrevMonth() {
|
||||
if (!this.data.canPrev) return
|
||||
let { currentYear, currentMonth } = this.data
|
||||
currentMonth--
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12
|
||||
currentYear--
|
||||
}
|
||||
this.setData({ currentYear, currentMonth })
|
||||
this.updateMonthView()
|
||||
},
|
||||
|
||||
/** 切换到下一月 */
|
||||
onNextMonth() {
|
||||
if (!this.data.canNext) return
|
||||
let { currentYear, currentMonth } = this.data
|
||||
currentMonth++
|
||||
if (currentMonth > 12) {
|
||||
currentMonth = 1
|
||||
currentYear++
|
||||
}
|
||||
this.setData({ currentYear, currentMonth })
|
||||
this.updateMonthView()
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData(this.data.customerId)
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 600)
|
||||
},
|
||||
|
||||
/** 重试 */
|
||||
onRetry() {
|
||||
this.loadData(this.data.customerId)
|
||||
},
|
||||
|
||||
/** 触底加载 */
|
||||
onReachBottom() {
|
||||
// Mock 阶段数据有限,不做分页
|
||||
if (this.data.loadingMore || !this.data.hasMore) return
|
||||
this.setData({ loadingMore: true })
|
||||
setTimeout(() => {
|
||||
this.setData({ loadingMore: false, hasMore: false })
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 查看/隐藏手机号 */
|
||||
onTogglePhone() {
|
||||
this.setData({ phoneVisible: !this.data.phoneVisible })
|
||||
},
|
||||
|
||||
/** 复制手机号 */
|
||||
onCopyPhone() {
|
||||
const phone = this.data.customerPhoneFull
|
||||
wx.setClipboardData({
|
||||
data: phone,
|
||||
success: () => {
|
||||
wx.showToast({ title: '手机号码已复制', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-empty description="暂无服务记录" />
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败,请点击重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:elif="{{pageState === 'normal'}}">
|
||||
|
||||
<!-- Banner(复用 task-detail 样式)-->
|
||||
<view class="banner-area">
|
||||
<image src="/assets/images/banner-bg-coral-aurora.svg" class="banner-bg-svg" mode="scaleToFill" />
|
||||
<view class="banner-content">
|
||||
<view class="customer-info">
|
||||
<view class="avatar-box">
|
||||
<text class="avatar-text">{{customerInitial}}</text>
|
||||
</view>
|
||||
<view class="info-right">
|
||||
<view class="name-row">
|
||||
<text class="customer-name">{{customerName}}</text>
|
||||
<view class="name-badges">
|
||||
<text class="name-badge">服务 <text class="badge-highlight">{{totalServiceCount}}</text> 次</text>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
<view class="sub-stats">
|
||||
<view class="sub-info">
|
||||
<text class="phone">{{phoneVisible ? customerPhoneFull : customerPhone}}</text>
|
||||
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
|
||||
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 月份切换 -->
|
||||
<view class="month-switcher">
|
||||
<view class="month-btn {{canPrev ? '' : 'disabled'}}" bindtap="onPrevMonth">
|
||||
<t-icon name="chevron-left" size="32rpx" color="{{canPrev ? '#777777' : '#dcdcdc'}}" />
|
||||
</view>
|
||||
<text class="month-label">{{monthLabel}}</text>
|
||||
<view class="month-btn {{canNext ? '' : 'disabled'}}" bindtap="onNextMonth">
|
||||
<t-icon name="chevron-right" size="32rpx" color="{{canNext ? '#777777' : '#dcdcdc'}}" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 月度统计概览 -->
|
||||
<view class="month-summary">
|
||||
<view class="summary-item">
|
||||
<text class="summary-label">本月服务</text>
|
||||
<text class="summary-value">{{monthCount}}</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-label">服务时长</text>
|
||||
<text class="summary-value value-primary">{{monthHours}}</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-label">关系指数</text>
|
||||
<text class="summary-value value-warning">{{monthRelation}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 记录列表(service-record-card 组件)-->
|
||||
<view class="records-container">
|
||||
|
||||
<!-- 无当月记录 -->
|
||||
<view class="no-month-data" wx:if="{{records.length === 0}}">
|
||||
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
|
||||
<text class="no-month-text">本月暂无服务记录</text>
|
||||
</view>
|
||||
|
||||
<service-record-card
|
||||
wx:for="{{records}}"
|
||||
wx:key="id"
|
||||
time="{{item.date}}"
|
||||
course-label="{{item.type}}"
|
||||
type-class="{{item.typeClass}}"
|
||||
type="{{item.recordType}}"
|
||||
table-no="{{item.table}}"
|
||||
hours="{{item.duration}}"
|
||||
hours-raw="{{item.durationRaw}}"
|
||||
drinks="{{item.drinks}}"
|
||||
income="{{item.income}}"
|
||||
is-estimate="{{item.isEstimate}}"
|
||||
/>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="list-footer" wx:if="{{records.length > 0}}">
|
||||
<text class="footer-text">— 已加载全部记录 —</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="loading-more" wx:if="{{loadingMore}}">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button customerId="{{customerId}}" />
|
||||
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
@@ -0,0 +1,285 @@
|
||||
/* pages/customer-service-records/customer-service-records.wxss */
|
||||
|
||||
page {
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
/* ========== 页面状态 ========== */
|
||||
.page-loading,
|
||||
.page-empty,
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.error-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-error, #e34d59);
|
||||
}
|
||||
.retry-btn {
|
||||
margin-top: 8rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
background: var(--color-primary, #0052d9);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
.retry-btn--hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ========== Banner(与 task-detail 一致)========== */
|
||||
.banner-area {
|
||||
position: relative;
|
||||
height: 202rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
|
||||
}
|
||||
|
||||
.banner-bg-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 270%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 44rpx 0 36rpx 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.customer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
border-radius: 30rpx;
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
margin-bottom: 14rpx;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.name-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.name-badge {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
padding: 4rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge-highlight {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.customer-phone {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sub-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.sub-stat {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
|
||||
.stat-highlight {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.sub-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.phone {
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
|
||||
.phone-toggle-btn {
|
||||
padding: 4rpx 14rpx;
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.phone-toggle-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
color: rgba(255, 255, 255, 0.90);
|
||||
}
|
||||
|
||||
.phone-toggle-btn--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ========== 月份切换 ========== */
|
||||
.month-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 48rpx;
|
||||
background: #ffffff;
|
||||
padding: 24rpx 32rpx;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
.month-btn {
|
||||
padding: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.month-btn.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.month-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
/* ========== 月度统计 ========== */
|
||||
.month-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: #ffffff;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
.value-primary { color: #0052d9; }
|
||||
.value-warning { color: #ed7b2f; }
|
||||
.summary-divider {
|
||||
width: 2rpx;
|
||||
height: 64rpx;
|
||||
background: #eeeeee;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* ========== 记录列表 ========== */
|
||||
.records-container {
|
||||
padding: 24rpx 30rpx;
|
||||
padding-bottom: 100rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* 无当月数据 */
|
||||
.no-month-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.no-month-text {
|
||||
font-size: 26rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
.list-footer {
|
||||
text-align: center;
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 20rpx;
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "开发调试面板",
|
||||
"usingComponents": {}
|
||||
}
|
||||
175
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.ts
Normal file
175
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 开发调试面板页面
|
||||
*
|
||||
* 功能:
|
||||
* - 展示当前用户上下文(角色、权限、绑定、门店)
|
||||
* - 一键切换角色(后端真实修改 user_site_roles + 重签 token)
|
||||
* - 一键切换用户状态(后端真实修改 users.status + 重签 token)
|
||||
* - 页面跳转列表(点击跳转到任意已注册页面)
|
||||
*/
|
||||
import { request } from "../../utils/request"
|
||||
|
||||
// 页面列表分三段:正在迁移、已完成、未完成
|
||||
const MIGRATING_PAGES = [
|
||||
{ path: "pages/my-profile/my-profile", name: "个人中心" },
|
||||
{ path: "pages/customer-service-records/customer-service-records", name: "客户服务记录" },
|
||||
{ path: "pages/chat/chat", name: "AI 对话" },
|
||||
{ path: "pages/chat-history/chat-history", name: "对话历史" },
|
||||
]
|
||||
|
||||
const DONE_PAGES = [
|
||||
{ path: "pages/performance-records/performance-records", name: "业绩明细" },
|
||||
{ path: "pages/coach-detail/coach-detail", name: "助教详情" },
|
||||
{ path: "pages/customer-detail/customer-detail", name: "客户详情" },
|
||||
{ path: "pages/performance/performance", name: "业绩总览" },
|
||||
{ path: "pages/task-detail/task-detail", name: "任务详情" },
|
||||
{ path: "pages/no-permission/no-permission", name: "无权限" },
|
||||
{ path: "pages/login/login", name: "登录" },
|
||||
{ path: "pages/apply/apply", name: "申请" },
|
||||
{ path: "pages/reviewing/reviewing", name: "审核中" },
|
||||
{ path: "pages/board-coach/board-coach", name: "助教看板" },
|
||||
{ path: "pages/board-customer/board-customer", name: "客户看板" },
|
||||
{ path: "pages/board-finance/board-finance", name: "财务看板" },
|
||||
{ path: "pages/task-list/task-list", name: "任务列表(新版)" },
|
||||
{ path: "pages/notes/notes", name: "备忘录" },
|
||||
]
|
||||
|
||||
const TODO_PAGES: typeof MIGRATING_PAGES = []
|
||||
|
||||
const ROLE_LIST = [
|
||||
{ code: "coach", name: "助教" },
|
||||
{ code: "staff", name: "员工" },
|
||||
{ code: "site_admin", name: "店铺管理员" },
|
||||
{ code: "tenant_admin", name: "租户管理员" },
|
||||
]
|
||||
|
||||
const STATUS_LIST = ["new", "pending", "approved", "rejected", "disabled"]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
ctx: null as any,
|
||||
loading: true,
|
||||
pages: [] as typeof TODO_PAGES,
|
||||
migratingPages: MIGRATING_PAGES,
|
||||
donePages: DONE_PAGES,
|
||||
todoPages: TODO_PAGES,
|
||||
roles: ROLE_LIST,
|
||||
statuses: STATUS_LIST,
|
||||
currentRole: "",
|
||||
rolesText: "-",
|
||||
permissionsText: "-",
|
||||
bindingText: "-",
|
||||
message: "",
|
||||
messageType: "",
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadContext()
|
||||
},
|
||||
|
||||
/** 加载当前用户调试上下文 */
|
||||
async loadContext() {
|
||||
// 没有 token 时不发请求,避免 401 → 刷新 → 跳转的无限循环
|
||||
const token = wx.getStorageSync("token")
|
||||
if (!token) {
|
||||
this.setData({ loading: false })
|
||||
this.showMsg("未登录,请先通过 dev-login 获取 token", "error")
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ loading: true, message: "" })
|
||||
try {
|
||||
const ctx = await request({ url: "/api/xcx/dev-context", method: "GET" })
|
||||
const rolesText = ctx.roles?.length ? ctx.roles.join(", ") : "-"
|
||||
const permissionsText = ctx.permissions?.length ? ctx.permissions.join(", ") : "-"
|
||||
let bindingText = "-"
|
||||
if (ctx.binding) {
|
||||
const b = ctx.binding
|
||||
bindingText = `${b.binding_type} (助教:${b.assistant_id || '-'} 员工:${b.staff_id || '-'})`
|
||||
}
|
||||
// 当前角色取第一个(通常只有一个)
|
||||
const currentRole = ctx.roles?.length ? ctx.roles[0] : ""
|
||||
this.setData({ ctx, rolesText, permissionsText, bindingText, currentRole, loading: false })
|
||||
} catch (err: any) {
|
||||
this.setData({ loading: false })
|
||||
// 401 说明 token 无效或是受限令牌,不触发重试
|
||||
const detail = err?.data?.detail || "网络错误"
|
||||
this.showMsg("获取上下文失败: " + detail, "error")
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换角色 */
|
||||
async switchRole(e: any) {
|
||||
const code = e.currentTarget.dataset.code
|
||||
if (code === this.data.currentRole) return
|
||||
wx.showLoading({ title: "切换中..." })
|
||||
try {
|
||||
const res = await request({
|
||||
url: "/api/xcx/dev-switch-role",
|
||||
method: "POST",
|
||||
data: { role_code: code },
|
||||
})
|
||||
// 保存新 token
|
||||
this.saveTokens(res)
|
||||
this.showMsg(`已切换为 ${code}`, "success")
|
||||
// 重新加载上下文
|
||||
this.loadContext()
|
||||
} catch (err: any) {
|
||||
this.showMsg("切换角色失败: " + (err?.data?.detail || "网络错误"), "error")
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换用户状态 */
|
||||
async switchStatus(e: any) {
|
||||
const status = e.currentTarget.dataset.status
|
||||
if (status === this.data.ctx?.status) return
|
||||
wx.showLoading({ title: "切换中..." })
|
||||
try {
|
||||
const res = await request({
|
||||
url: "/api/xcx/dev-switch-status",
|
||||
method: "POST",
|
||||
data: { status },
|
||||
})
|
||||
// 保存新 token
|
||||
this.saveTokens(res)
|
||||
this.showMsg(`状态已切换为 ${status}`, "success")
|
||||
this.loadContext()
|
||||
} catch (err: any) {
|
||||
this.showMsg("切换状态失败: " + (err?.data?.detail || "网络错误"), "error")
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
/** 跳转到指定页面 */
|
||||
goPage(e: any) {
|
||||
const url = "/" + e.currentTarget.dataset.url
|
||||
// 使用 reLaunch 确保能跳转到任意页面(包括 tabBar 页面)
|
||||
wx.reLaunch({ url })
|
||||
},
|
||||
|
||||
/** 保存后端返回的新 token */
|
||||
saveTokens(res: any) {
|
||||
if (res.access_token && res.refresh_token) {
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.token = res.access_token
|
||||
app.globalData.refreshToken = res.refresh_token
|
||||
wx.setStorageSync("token", res.access_token)
|
||||
wx.setStorageSync("refreshToken", res.refresh_token)
|
||||
if (res.user_id) {
|
||||
wx.setStorageSync("userId", res.user_id)
|
||||
}
|
||||
if (res.user_status) {
|
||||
wx.setStorageSync("userStatus", res.user_status)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** 显示操作提示 */
|
||||
showMsg(msg: string, type: "success" | "error") {
|
||||
this.setData({ message: msg, messageType: type })
|
||||
setTimeout(() => this.setData({ message: "" }), 3000)
|
||||
},
|
||||
})
|
||||
109
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.wxml
Normal file
109
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.wxml
Normal 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>
|
||||
171
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.wxss
Normal file
171
apps/DEMO-miniprogram/miniprogram/pages/dev-tools/dev-tools.wxss
Normal 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;
|
||||
}
|
||||
8
apps/DEMO-miniprogram/miniprogram/pages/login/login.json
Normal file
8
apps/DEMO-miniprogram/miniprogram/pages/login/login.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
63
apps/DEMO-miniprogram/miniprogram/pages/login/login.ts
Normal file
63
apps/DEMO-miniprogram/miniprogram/pages/login/login.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// DEMO 演示版 — 点击登录模拟成功,写入 mock 用户信息后跳转主页
|
||||
|
||||
Page({
|
||||
data: {
|
||||
agreed: false,
|
||||
loading: false,
|
||||
/** 系统状态栏高度(px),用于自定义导航栏顶部偏移 */
|
||||
statusBarHeight: 20,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const windowInfo = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight: windowInfo.statusBarHeight || 20 })
|
||||
},
|
||||
|
||||
onAgreeChange() {
|
||||
this.setData({ agreed: !this.data.agreed })
|
||||
},
|
||||
|
||||
async onLogin() {
|
||||
if (!this.data.agreed) {
|
||||
wx.showToast({ title: "请先同意用户协议", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (this.data.loading) return
|
||||
|
||||
this.setData({ loading: true })
|
||||
|
||||
// DEMO:模拟登录成功,写入 mock 用户信息
|
||||
const mockToken = "demo_token_" + Date.now()
|
||||
const mockUser = {
|
||||
userId: "demo_user_001",
|
||||
status: "approved",
|
||||
nickname: "小燕",
|
||||
role: "coach",
|
||||
coachLevel: "senior",
|
||||
storeName: "朗朗桌球·旗舰店",
|
||||
avatar: "",
|
||||
}
|
||||
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.token = mockToken
|
||||
app.globalData.refreshToken = "demo_refresh_token"
|
||||
app.globalData.authUser = mockUser
|
||||
|
||||
wx.setStorageSync("token", mockToken)
|
||||
wx.setStorageSync("refreshToken", "demo_refresh_token")
|
||||
wx.setStorageSync("userId", mockUser.userId)
|
||||
wx.setStorageSync("userStatus", mockUser.status)
|
||||
wx.setStorageSync("userRole", mockUser.role)
|
||||
wx.setStorageSync("storeName", mockUser.storeName)
|
||||
wx.setStorageSync("coachLevel", mockUser.coachLevel)
|
||||
wx.setStorageSync("avatar", mockUser.avatar)
|
||||
|
||||
wx.showToast({ title: "登录成功", icon: "success" })
|
||||
|
||||
// 500ms 后跳转主页
|
||||
setTimeout(() => {
|
||||
this.setData({ loading: false })
|
||||
wx.reLaunch({ url: "/pages/task-list/task-list" })
|
||||
}, 500)
|
||||
},
|
||||
})
|
||||
77
apps/DEMO-miniprogram/miniprogram/pages/login/login.wxml
Normal file
77
apps/DEMO-miniprogram/miniprogram/pages/login/login.wxml
Normal file
@@ -0,0 +1,77 @@
|
||||
<!--pages/login/login.wxml — 忠于 H5 原型,使用 TDesign 组件 -->
|
||||
<view class="page" style="padding-top: {{statusBarHeight}}px;">
|
||||
<!-- 动态背景层 -->
|
||||
<image class="login-bg-layer" src="/assets/images/login-bg-animated.svg" mode="scaleToFill" />
|
||||
|
||||
<!-- 装饰元素:模拟 blur 圆形 -->
|
||||
<view class="deco-circle deco-circle--1"></view>
|
||||
<view class="deco-circle deco-circle--2"></view>
|
||||
<view class="deco-circle deco-circle--3"></view>
|
||||
|
||||
<!-- 顶部区域 - Logo 和名称 -->
|
||||
<view class="hero">
|
||||
<!-- Logo:浮动动画 -->
|
||||
<view class="logo-wrap float-animation">
|
||||
<view class="logo-box">
|
||||
<image class="logo-icon" src="/assets/icons/logo-billiard.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<!-- 装饰点 -->
|
||||
<view class="logo-dot logo-dot--tr"></view>
|
||||
<view class="logo-dot logo-dot--bl"></view>
|
||||
</view>
|
||||
|
||||
<!-- 应用名称 -->
|
||||
<text class="app-name">球房运营助手</text>
|
||||
<text class="app-desc">为台球厅提升运营效率的内部管理工具</text>
|
||||
|
||||
<!-- 功能亮点 -->
|
||||
<view class="features">
|
||||
<view class="feature-item">
|
||||
<view class="feature-icon-svg feature-icon-svg--task">
|
||||
<image class="feature-svg-img" src="/assets/icons/feature-task.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="feature-text">任务管理</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<view class="feature-icon-svg feature-icon-svg--board">
|
||||
<image class="feature-svg-img" src="/assets/icons/feature-board.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="feature-text">数据看板</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<view class="feature-icon-svg feature-icon-svg--ai">
|
||||
<image class="feature-svg-img" src="/assets/icons/feature-ai.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="feature-text">智能助手</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部区域 - 登录按钮和协议 -->
|
||||
<view class="bottom-area">
|
||||
<!-- 微信登录按钮 -->
|
||||
<view class="login-btn-wrap {{(!agreed || loading) ? 'login-btn--disabled' : 'login-btn--active'}}" bindtap="onLogin">
|
||||
<image wx:if="{{!loading}}" class="wechat-icon" src="/assets/icons/icon-wechat.svg" mode="aspectFit" />
|
||||
<t-loading wx:if="{{loading}}" theme="circular" size="32rpx" color="#fff" />
|
||||
<text class="login-btn-text">使用微信登录</text>
|
||||
</view>
|
||||
|
||||
<!-- 协议勾选 -->
|
||||
<view class="agreement" bindtap="onAgreeChange">
|
||||
<view class="checkbox {{agreed ? 'checkbox--checked' : ''}}">
|
||||
<t-icon wx:if="{{agreed}}" name="check" size="18rpx" color="#fff" />
|
||||
</view>
|
||||
<view class="agreement-text-wrap">
|
||||
<text class="agreement-text">我已阅读并同意</text>
|
||||
<text class="link">《用户协议》</text>
|
||||
<text class="agreement-text">和</text>
|
||||
<text class="link">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部说明 -->
|
||||
<text class="footer-tip">仅限球房内部员工使用</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
337
apps/DEMO-miniprogram/miniprogram/pages/login/login.wxss
Normal file
337
apps/DEMO-miniprogram/miniprogram/pages/login/login.wxss
Normal file
@@ -0,0 +1,337 @@
|
||||
/* pages/login/login.wxss — 忠于 H5 原型,TDesign 组件定制 */
|
||||
|
||||
.page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(160deg, #dbeafe 0%, #eff6ff 45%, #e0f2fe 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
/* padding-top 由 JS statusBarHeight 动态设置,box-sizing 确保 padding 包含在 100vh 内 */
|
||||
}
|
||||
|
||||
/* ---- 动态背景层 ---- */
|
||||
.login-bg-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- 装饰圆形 ---- */
|
||||
.deco-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.deco-circle--1 {
|
||||
width: 176rpx;
|
||||
height: 176rpx;
|
||||
background: radial-gradient(circle, var(--color-primary-shadow-minimal) 0%, transparent 70%);
|
||||
top: 140rpx;
|
||||
left: 56rpx;
|
||||
opacity: 0.8;
|
||||
animation: pulse-soft 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.deco-circle--2 {
|
||||
width: 210rpx;
|
||||
height: 210rpx;
|
||||
background: radial-gradient(circle, rgba(103, 232, 249, 0.12) 0%, transparent 70%);
|
||||
top: 280rpx;
|
||||
right: 42rpx;
|
||||
opacity: 0.8;
|
||||
animation: pulse-soft 3s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
.deco-circle--3 {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
background: radial-gradient(circle, rgba(96, 165, 250, 0.12) 0%, transparent 70%);
|
||||
bottom: 280rpx;
|
||||
left: 84rpx;
|
||||
opacity: 0.8;
|
||||
animation: pulse-soft 3s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
/* ---- Hero 区域 ---- */
|
||||
.hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 56rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- Logo ---- */
|
||||
.logo-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 42rpx;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
width: 168rpx;
|
||||
height: 168rpx;
|
||||
border-radius: 42rpx;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 14rpx 42rpx var(--color-primary-shadow);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 98rpx;
|
||||
height: 98rpx;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-dot--tr {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
background: var(--primary-dot-cyan);
|
||||
top: -8rpx;
|
||||
right: -8rpx;
|
||||
box-shadow: 0 4rpx 10rpx var(--primary-dot-cyan-shadow);
|
||||
}
|
||||
|
||||
.logo-dot--bl {
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
background: var(--primary-dot-blue);
|
||||
bottom: -14rpx;
|
||||
left: -14rpx;
|
||||
box-shadow: 0 4rpx 10rpx var(--primary-dot-blue-shadow);
|
||||
}
|
||||
|
||||
/* ---- 应用名称 ---- */
|
||||
.app-name {
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.625;
|
||||
margin-bottom: 56rpx;
|
||||
}
|
||||
|
||||
/* ---- 功能亮点 ---- */
|
||||
.features {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border-radius: 32rpx;
|
||||
padding: 40rpx 18rpx 36rpx;
|
||||
margin-bottom: 42rpx;
|
||||
box-shadow: 0 4rpx 32rpx rgba(59, 130, 246, 0.10), 0 1rpx 4rpx rgba(59, 130, 246, 0.07);
|
||||
border: 1.5rpx solid rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-icon--primary { background: var(--color-primary-shadow-minimal); }
|
||||
.feature-icon--success { background: var(--color-success-shadow-minimal); }
|
||||
.feature-icon--warning { background: var(--color-warning-shadow-minimal); }
|
||||
|
||||
/* ---- 华丽 SVG 功能图标 ---- */
|
||||
.feature-icon-svg {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.feature-icon-svg--task {
|
||||
background: linear-gradient(145deg, #e8f0fe 0%, #c7d9ff 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(79, 142, 247, 0.28), 0 2rpx 6rpx rgba(26, 95, 216, 0.15);
|
||||
animation: icon-float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.feature-icon-svg--board {
|
||||
background: linear-gradient(145deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 200, 150, 0.28), 0 2rpx 6rpx rgba(0, 140, 106, 0.15);
|
||||
animation: icon-float 4s ease-in-out infinite 1.3s;
|
||||
}
|
||||
|
||||
.feature-icon-svg--ai {
|
||||
background: linear-gradient(145deg, #fef3e2 0%, #fde0b8 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(255, 140, 66, 0.28), 0 2rpx 6rpx rgba(224, 90, 0, 0.15);
|
||||
animation: icon-float 4s ease-in-out infinite 2.6s;
|
||||
}
|
||||
|
||||
.feature-svg-img {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
@keyframes icon-float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
40% { transform: translateY(-8rpx) scale(1.04); }
|
||||
60% { transform: translateY(-6rpx) scale(1.03); }
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
/* ---- 底部操作区 ---- */
|
||||
.bottom-area {
|
||||
width: 100%;
|
||||
padding: 0 56rpx 70rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* TDesign Button 替换为原生 view 按钮,避免 TDesign 默认样式干扰 */
|
||||
.login-btn-wrap {
|
||||
width: 100%;
|
||||
height: 84rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
font-weight: 500;
|
||||
font-size: 28rpx;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.login-btn-text {
|
||||
color: var(--color-white);
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 未勾选协议 → 灰色禁用态(忠于原型 btn-disabled) */
|
||||
.login-btn--disabled {
|
||||
background: var(--color-gray-4);
|
||||
}
|
||||
.login-btn--disabled .login-btn-text {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
/* 勾选协议 → 蓝色渐变(忠于原型 from-primary to-blue-500) */
|
||||
.login-btn--active {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--primary-500));
|
||||
box-shadow: 0 10rpx 28rpx var(--color-primary-shadow);
|
||||
}
|
||||
|
||||
/* ---- 协议勾选 ---- */
|
||||
.agreement {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-top: 36rpx;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
min-width: 28rpx;
|
||||
min-height: 28rpx;
|
||||
border: 2rpx solid var(--color-gray-4);
|
||||
border-radius: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4rpx;
|
||||
margin-right: 10rpx;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox--checked {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.agreement-text-wrap {
|
||||
flex: 1;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* ---- 底部提示 ---- */
|
||||
.footer-tip {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-disabled);
|
||||
margin-top: 42rpx;
|
||||
}
|
||||
|
||||
/* ---- 动画 ---- */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-14rpx); }
|
||||
}
|
||||
|
||||
.float-animation {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mockUserProfile } from '../../utils/mock-data'
|
||||
import { getMenuRoute, navigateTo } from '../../utils/router'
|
||||
|
||||
// TODO: 联调时替换为真实 API 获取用户信息
|
||||
Page({
|
||||
data: {
|
||||
userInfo: mockUserProfile,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 同步 custom-tab-bar 选中态
|
||||
const tabBar = this.getTabBar?.()
|
||||
if (tabBar) tabBar.setData({ active: 'my' })
|
||||
// TODO: 联调时在此刷新用户信息
|
||||
},
|
||||
|
||||
onMenuTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const key = e.currentTarget.dataset.key as string
|
||||
const route = getMenuRoute(key)
|
||||
if (route) {
|
||||
navigateTo(route)
|
||||
}
|
||||
},
|
||||
|
||||
onLogout() {
|
||||
wx.showModal({
|
||||
title: '确认退出',
|
||||
content: '确认退出当前账号吗?',
|
||||
confirmColor: '#e34d59',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
wx.clearStorageSync()
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- 我的页面 -->
|
||||
<view class="page-my-profile">
|
||||
|
||||
<!-- 用户信息区域 -->
|
||||
<view class="user-card">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar" src="{{userInfo.avatar}}" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<view class="name-row">
|
||||
<text class="name">{{userInfo.name}}</text>
|
||||
<text class="role-tag">{{userInfo.role}}</text>
|
||||
</view>
|
||||
<text class="store-name">{{userInfo.storeName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<view class="menu-list">
|
||||
|
||||
<!-- 备注记录 -->
|
||||
<view class="menu-item" hover-class="menu-item--hover" bind:tap="onMenuTap" data-key="notes">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon icon-notes">
|
||||
<image src="/assets/icons/menu-notes.svg" class="menu-icon-img" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">备注记录</text>
|
||||
</view>
|
||||
<view class="menu-chevron">
|
||||
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 助手对话记录 -->
|
||||
<view class="menu-item" hover-class="menu-item--hover" bind:tap="onMenuTap" data-key="chat-history">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon icon-chat">
|
||||
<image src="/assets/icons/menu-chat.svg" class="menu-icon-img" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">助手对话记录</text>
|
||||
</view>
|
||||
<view class="menu-chevron">
|
||||
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出账号 -->
|
||||
<view class="menu-item menu-item--last" hover-class="menu-item--hover" bind:tap="onLogout">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon icon-logout">
|
||||
<image src="/assets/icons/menu-logout.svg" class="menu-icon-img" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="menu-text">退出账号</text>
|
||||
</view>
|
||||
<view class="menu-chevron">
|
||||
<t-icon name="chevron-right" size="28rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx -->
|
||||
<ai-float-button visible="{{true}}" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
@@ -0,0 +1,135 @@
|
||||
.page-my-profile {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-page, #f3f3f3);
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
padding: 44rpx 40rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.10);
|
||||
flex-shrink: 0;
|
||||
border: 4rpx solid #fff;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #242424);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
color: #0052d9;
|
||||
font-size: 22rpx;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 500;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-placeholder, #8b8b8b);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 菜单列表 */
|
||||
.menu-list {
|
||||
margin-top: 24rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 36rpx;
|
||||
background: #fff;
|
||||
border-bottom: 2rpx solid #f3f3f3;
|
||||
}
|
||||
|
||||
.menu-item--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item--hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon-img {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
.icon-notes {
|
||||
background: rgba(0, 82, 217, 0.08);
|
||||
}
|
||||
|
||||
.icon-chat {
|
||||
background: rgba(0, 168, 112, 0.08);
|
||||
}
|
||||
|
||||
.icon-logout {
|
||||
background: rgba(227, 77, 89, 0.08);
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--color-text-primary, #242424);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menu-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "无权限",
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// pages/no-permission/no-permission.ts
|
||||
// 无权限页面 — 账号已禁用或无访问权限时展示
|
||||
// onShow 时查询最新状态,状态变化时自动跳转
|
||||
|
||||
import { request } from "../../utils/request"
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 0,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const { statusBarHeight = 0 } = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this._checkStatus()
|
||||
},
|
||||
|
||||
/** 查询最新用户状态,非 disabled 时自动跳转 */
|
||||
async _checkStatus() {
|
||||
const token = wx.getStorageSync("token")
|
||||
if (!token) {
|
||||
wx.reLaunch({ url: "/pages/login/login" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await request({
|
||||
url: "/api/xcx/me",
|
||||
method: "GET",
|
||||
needAuth: true,
|
||||
})
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.authUser = {
|
||||
userId: data.user_id,
|
||||
status: data.status,
|
||||
nickname: data.nickname,
|
||||
}
|
||||
wx.setStorageSync("userId", data.user_id)
|
||||
wx.setStorageSync("userStatus", data.status)
|
||||
|
||||
switch (data.status) {
|
||||
case "disabled":
|
||||
case "rejected":
|
||||
break // 留在当前页
|
||||
case "approved":
|
||||
wx.reLaunch({ url: "/pages/mvp/mvp" })
|
||||
break
|
||||
case "pending":
|
||||
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
|
||||
break
|
||||
case "new":
|
||||
wx.reLaunch({ url: "/pages/apply/apply" })
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// 网络错误不阻塞
|
||||
}
|
||||
},
|
||||
|
||||
/** 更换登录账号:清除凭证后跳转登录页 */
|
||||
onSwitchAccount() {
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.token = undefined
|
||||
app.globalData.refreshToken = undefined
|
||||
app.globalData.authUser = undefined
|
||||
wx.removeStorageSync("token")
|
||||
wx.removeStorageSync("refreshToken")
|
||||
wx.removeStorageSync("userId")
|
||||
wx.removeStorageSync("userStatus")
|
||||
wx.reLaunch({ url: "/pages/login/login" })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- pages/no-permission/no-permission.wxml — 按 H5 原型结构迁移 -->
|
||||
<view class="page" style="padding-top: {{statusBarHeight}}px;">
|
||||
<!-- 十字纹背景图案(H5 bg-pattern,error 色) -->
|
||||
<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 />
|
||||
@@ -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;
|
||||
}
|
||||
220
apps/DEMO-miniprogram/miniprogram/pages/notes/DELETE_FEATURE.md
Normal file
220
apps/DEMO-miniprogram/miniprogram/pages/notes/DELETE_FEATURE.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Notes 页面删除功能实现说明
|
||||
|
||||
## 更新日期
|
||||
2026-03-14
|
||||
|
||||
## 功能描述
|
||||
为每条备注添加删除按钮,放置在日期左侧,交互和逻辑参考任务详情页的备注删除处理方式。
|
||||
|
||||
---
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. WXML 结构调整
|
||||
|
||||
#### 原结构
|
||||
```xml
|
||||
<view class="note-bottom">
|
||||
<text class="note-tag">{{item.tagLabel}}</text>
|
||||
<text class="note-time">{{item.createdAt}}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
#### 新结构
|
||||
```xml
|
||||
<view class="note-bottom">
|
||||
<view class="note-bottom-left">
|
||||
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
|
||||
<t-icon name="delete" size="20px" color="#a6a6a6" />
|
||||
</view>
|
||||
<text class="note-time">{{item.createdAt}}</text>
|
||||
</view>
|
||||
<text class="note-tag">{{item.tagLabel}}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 使用 `catchtap` 而非 `bindtap`,阻止事件冒泡
|
||||
- 删除按钮放在左侧,日期紧随其后
|
||||
- 标签移到右侧
|
||||
- 通过 `data-id` 传递备注ID
|
||||
|
||||
### 2. WXSS 样式添加
|
||||
|
||||
```wxss
|
||||
/* 底部容器布局调整 */
|
||||
.note-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 左侧容器(删除按钮 + 日期)*/
|
||||
.note-bottom-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 删除按钮样式 */
|
||||
.note-delete-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-delete-btn--hover {
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
```
|
||||
|
||||
**样式说明**:
|
||||
- 删除按钮尺寸:32px × 32px(符合最小点击区域44px的80%)
|
||||
- Icon尺寸:20px(与H5原型对齐)
|
||||
- Icon颜色:#a6a6a6(gray-6,次级文字颜色)
|
||||
- Hover效果:浅灰背景(#f3f3f3)
|
||||
- 按钮与日期间距:8px
|
||||
|
||||
### 3. TypeScript 逻辑实现
|
||||
|
||||
```typescript
|
||||
/** 删除备注 */
|
||||
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
|
||||
const noteId = e.currentTarget.dataset.id as string
|
||||
wx.showModal({
|
||||
title: '删除备注',
|
||||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||||
confirmColor: '#e34d59',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const notes = this.data.notes.filter((n) => n.id !== noteId)
|
||||
this.setData({ notes })
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑说明**:
|
||||
- 从 `dataset` 中获取备注ID
|
||||
- 使用 `wx.showModal` 显示确认弹窗
|
||||
- 确认按钮颜色:#e34d59(error色,警示作用)
|
||||
- 确认后过滤掉对应备注
|
||||
- 显示成功提示
|
||||
|
||||
---
|
||||
|
||||
## 参考实现
|
||||
|
||||
### 任务详情页删除逻辑
|
||||
位置:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
|
||||
|
||||
```typescript
|
||||
/** 删除备注 */
|
||||
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
|
||||
const noteId = e.currentTarget.dataset.id as string
|
||||
wx.showModal({
|
||||
title: '删除备注',
|
||||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||||
confirmColor: '#e34d59',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
|
||||
this.setData({ sortedNotes: notes })
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**差异点**:
|
||||
- 任务详情页使用 `sortedNotes`,notes页使用 `notes`
|
||||
- 其他逻辑完全一致
|
||||
|
||||
---
|
||||
|
||||
## 交互流程
|
||||
|
||||
1. 用户点击删除按钮
|
||||
2. 显示确认弹窗:"确定要删除这条备注吗?删除后无法恢复。"
|
||||
3. 用户选择:
|
||||
- **确认**:删除备注,显示"已删除"提示
|
||||
- **取消**:关闭弹窗,不做任何操作
|
||||
|
||||
---
|
||||
|
||||
## 视觉效果
|
||||
|
||||
### 布局
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 备注内容文本... │
|
||||
│ │
|
||||
│ [🗑️] 2024-11-27 16:00 [客户:王先生] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 尺寸对比
|
||||
| 元素 | 尺寸 | 说明 |
|
||||
|------|------|------|
|
||||
| 删除按钮容器 | 32px × 32px | 圆形点击区域 |
|
||||
| 删除icon | 20px | TDesign delete图标 |
|
||||
| 按钮与日期间距 | 8px | gap |
|
||||
| 日期字号 | 12px | text-xs |
|
||||
| 标签字号 | 12px | text-xs |
|
||||
|
||||
---
|
||||
|
||||
## 颜色规范
|
||||
|
||||
| 元素 | 颜色 | 色值 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 删除icon | gray-6 | #a6a6a6 | 次级文字颜色 |
|
||||
| Hover背景 | gray-1 | #f3f3f3 | 浅灰背景 |
|
||||
| 确认按钮 | error | #e34d59 | 警示色 |
|
||||
|
||||
---
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [x] 删除按钮显示正确
|
||||
- [x] 删除按钮位置正确(日期左侧)
|
||||
- [x] 点击删除按钮显示确认弹窗
|
||||
- [x] 确认删除后备注消失
|
||||
- [x] 取消删除后备注保留
|
||||
- [x] 删除成功显示提示
|
||||
- [x] Hover效果正常
|
||||
- [x] 事件不冒泡(使用catchtap)
|
||||
- [x] 代码无linting错误
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
1. **notes.wxml** - 添加删除按钮结构
|
||||
2. **notes.wxss** - 添加删除按钮样式
|
||||
3. **notes.ts** - 添加删除逻辑
|
||||
4. **DELETE_FEATURE.md** - 本文档
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **API集成**:当前为前端删除,需要对接后端API实现真实删除
|
||||
2. **撤销功能**:考虑添加"撤销删除"功能(3秒内可撤销)
|
||||
3. **批量删除**:如果需要,可以添加批量删除功能
|
||||
4. **权限控制**:根据用户角色控制删除权限
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 桥文档:`docs/miniprogram-dev/h5-to-mp-bridge-v3.md`
|
||||
- 任务详情页:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
|
||||
- TDesign图标:https://tdesign.tencent.com/miniprogram/components/icon
|
||||
185
apps/DEMO-miniprogram/miniprogram/pages/notes/MIGRATION_NOTES.md
Normal file
185
apps/DEMO-miniprogram/miniprogram/pages/notes/MIGRATION_NOTES.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Notes 页面迁移说明
|
||||
|
||||
## 迁移版本
|
||||
- **H5 原型基准**:`docs/h5_ui/pages/notes.html`
|
||||
- **桥文档版本**:v3.0
|
||||
- **迁移日期**:2026-03-14
|
||||
|
||||
---
|
||||
|
||||
## 样式调整清单
|
||||
|
||||
### 1. 单位转换规则
|
||||
遵循桥文档 §2.3 的混合单位策略:
|
||||
- **主要使用 `px`**:因为notes页面的尺寸需要与H5原型1:1对齐
|
||||
- **换算基准**:H5 CSS px → 小程序 px(412宽设备验收基准)
|
||||
|
||||
### 2. 导航栏(.custom-nav)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| height | 44px | 44px | 标准导航栏高度 |
|
||||
| padding | 0 16px | 0 16px | 左右内边距 |
|
||||
| border-bottom | 1px solid | 1px solid #eeeeee | 分割线 |
|
||||
|
||||
**icon 尺寸调整**:
|
||||
- H5: `w-5 h-5` (20px)
|
||||
- 小程序: `size="24px"` (考虑小程序点击区域最小44px)
|
||||
|
||||
### 3. 备注卡片(.note-card)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| border-radius | rounded-2xl (16px) | 16px | 圆角 |
|
||||
| padding | p-4 (16px) | 16px | 内边距 |
|
||||
| box-shadow | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 阴影 |
|
||||
| margin-bottom | mb-3 (12px) | 12px | 卡片间距 |
|
||||
|
||||
### 4. 文本样式
|
||||
|
||||
#### 备注内容(.note-content)
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| font-size | text-sm (14px) | 14px | 正文字号 |
|
||||
| line-height | leading-relaxed (1.625) | 20px | 行高(按附录B标准) |
|
||||
| color | text-gray-13 | #242424 | 正文颜色 |
|
||||
| margin-bottom | mb-3 (12px) | 12px | 下边距 |
|
||||
|
||||
**line-height 设置规则**(附录B.2.3):
|
||||
- 在外层 `<view>` 的 class 上设置 `line-height`
|
||||
- `<text>` 会自动继承,不需要额外设置
|
||||
|
||||
#### 标签(.note-tag)
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| padding | px-2.5 py-1 (10px 4px) | 4px 10px | 标签内边距 |
|
||||
| font-size | text-xs (12px) | 12px | 标签字号 |
|
||||
| border-radius | rounded-lg (8px) | 8px | 圆角 |
|
||||
| border | 1px solid | 1px solid | 边框 |
|
||||
|
||||
#### 时间戳(.note-time)
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| font-size | text-xs (12px) | 12px | 时间字号 |
|
||||
| color | text-gray-6 | #a6a6a6 | 次级文字颜色 |
|
||||
|
||||
### 5. 列表容器(.note-list)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| padding | p-4 (16px) | 16px | 列表外边距 |
|
||||
| gap | space-y-3 (12px) | 12px | 卡片间距 |
|
||||
|
||||
### 6. 颜色映射(附录C)
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 主品牌色 | #0052d9 | primary |
|
||||
| 成功色 | #00a870 | success |
|
||||
| 背景色 | #f3f3f3 | gray-1 |
|
||||
| 正文色 | #242424 | gray-13 |
|
||||
| 次级文字 | #a6a6a6 | gray-6 |
|
||||
| 分割线 | #eeeeee | gray-2 |
|
||||
|
||||
### 7. 标签渐变背景
|
||||
|
||||
**客户标签**:
|
||||
```css
|
||||
background: linear-gradient(135deg, #ecf2fe, #e8eeff);
|
||||
color: #0052d9;
|
||||
border-color: #c5d4f7;
|
||||
```
|
||||
|
||||
**助教标签**:
|
||||
```css
|
||||
background: linear-gradient(135deg, #e8faf0, #e0f7ea);
|
||||
color: #00a870;
|
||||
border-color: #b3e6d0;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键调整点
|
||||
|
||||
### 1. 全局 line-height 设置
|
||||
```wxss
|
||||
page {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
```
|
||||
**原因**:微信小程序 `<text>` 组件不能直接设置 `line-height`,必须在外层 `<view>` 设置(附录B.2.3)
|
||||
|
||||
### 2. 单位统一为 px
|
||||
- 原小程序版本混用 `rpx` 和 CSS 变量
|
||||
- 调整为直接使用 `px`,确保与H5原型1:1对齐
|
||||
- 所有尺寸基于412宽设备验收基准
|
||||
|
||||
### 3. 移除 CSS 变量依赖
|
||||
- 原:`var(--color-gray-2, #eeeeee)`
|
||||
- 新:直接使用 `#eeeeee`
|
||||
- 原因:确保样式独立,不依赖全局变量
|
||||
|
||||
### 4. Icon 尺寸调整
|
||||
- 原:`size="48rpx"` (约88px)
|
||||
- 新:`size="24px"` (标准导航栏icon尺寸)
|
||||
- 原因:与H5原型中的 `w-5 h-5` 对齐
|
||||
|
||||
### 5. 加载态 icon 尺寸
|
||||
- 原:`size="80rpx"` (约146px)
|
||||
- 新:`size="40px"` (适配小屏幕)
|
||||
|
||||
---
|
||||
|
||||
## 附录参考
|
||||
|
||||
### 使用的附录
|
||||
1. **附录A**:Spacing 与尺寸字典 v3.0
|
||||
- 间距换算:16px → 16px(px模式)
|
||||
- 圆角:8px, 16px
|
||||
|
||||
2. **附录B**:字体与文本字典 v3.1
|
||||
- text-sm: 14px / 20px
|
||||
- text-xs: 12px / 16px
|
||||
- **关键规则**:line-height 必须在外层 view 上设置
|
||||
|
||||
3. **附录C**:颜色字典 v3.0
|
||||
- 项目自定义颜色表
|
||||
- 透明度变体处理
|
||||
|
||||
4. **附录D**:布局类映射字典 v3.0
|
||||
- flex 布局
|
||||
- gap 间距
|
||||
|
||||
---
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [x] 导航栏高度与H5对齐(44px)
|
||||
- [x] 卡片圆角与H5对齐(16px)
|
||||
- [x] 文字大小与H5对齐(14px/12px)
|
||||
- [x] 行高设置正确(在view上设置)
|
||||
- [x] 颜色值与H5对齐
|
||||
- [x] 间距与H5对齐(16px padding, 12px gap)
|
||||
- [x] 标签样式与H5对齐(渐变背景)
|
||||
- [x] 阴影效果与H5对齐(shadow-sm)
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **安全区处理**:使用 `statusBarHeight` 动态计算,不使用 `env(safe-area-inset-*)`
|
||||
2. **AI 悬浮按钮**:bottom 值为 120px,需根据实际设备调整
|
||||
3. **页面滚动**:使用页面自然滚动,未使用 scroll-view
|
||||
|
||||
---
|
||||
|
||||
## 后续维护
|
||||
|
||||
- 若真机测试发现尺寸偏差,优先检查 line-height 设置
|
||||
- 若需要调整间距,参考附录A的spacing表
|
||||
- 若需要调整字号,参考附录B的字体表
|
||||
- 颜色调整参考附录C的颜色字典
|
||||
@@ -0,0 +1,139 @@
|
||||
# Notes 页面 H5 → 小程序 样式对比表
|
||||
|
||||
## 快速参考
|
||||
|
||||
### 导航栏
|
||||
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|
||||
|------|-----------|----------|-----------|
|
||||
| 导航栏高度 | h-11 | 44px | 44px |
|
||||
| 导航栏 padding | px-4 | 0 16px | 0 16px |
|
||||
| 返回按钮大小 | w-5 h-5 | 20px | 44px (容器) |
|
||||
| 返回 icon 尺寸 | — | — | 24px |
|
||||
| 标题字号 | text-base | 16px | 16px |
|
||||
| 标题字重 | font-medium | 500 | 500 |
|
||||
| 分割线 | border-b border-gray-2 | 1px solid #eeeeee | 1px solid #eeeeee |
|
||||
|
||||
### 备注卡片
|
||||
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|
||||
|------|-----------|----------|-----------|
|
||||
| 卡片圆角 | rounded-2xl | 16px | 16px |
|
||||
| 卡片 padding | p-4 | 16px | 16px |
|
||||
| 卡片阴影 | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 0 1px 2px rgba(0,0,0,0.05) |
|
||||
| 卡片间距 | space-y-3 | 12px | 12px |
|
||||
| 背景色 | bg-white | #ffffff | #ffffff |
|
||||
|
||||
### 文本样式
|
||||
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|
||||
|------|-----------|----------|-----------|
|
||||
| 备注内容字号 | text-sm | 14px | 14px |
|
||||
| 备注内容行高 | leading-relaxed | 1.625 (26px) | 20px |
|
||||
| 备注内容颜色 | text-gray-13 | #242424 | #242424 |
|
||||
| 备注内容下边距 | mb-3 | 12px | 12px |
|
||||
| 标签字号 | text-xs | 12px | 12px |
|
||||
| 标签 padding | px-2.5 py-1 | 10px 4px | 4px 10px |
|
||||
| 标签圆角 | rounded-lg | 8px | 8px |
|
||||
| 时间字号 | text-xs | 12px | 12px |
|
||||
| 时间颜色 | text-gray-6 | #a6a6a6 | #a6a6a6 |
|
||||
|
||||
### 颜色
|
||||
| 用途 | H5 Tailwind | 色值 | 小程序 WXSS |
|
||||
|------|-----------|------|-----------|
|
||||
| 页面背景 | bg-gray-1 | #f3f3f3 | #f3f3f3 |
|
||||
| 卡片背景 | bg-white | #ffffff | #ffffff |
|
||||
| 正文 | text-gray-13 | #242424 | #242424 |
|
||||
| 次级文字 | text-gray-6 | #a6a6a6 | #a6a6a6 |
|
||||
| 分割线 | border-gray-2 | #eeeeee | #eeeeee |
|
||||
| 客户标签文字 | text-primary | #0052d9 | #0052d9 |
|
||||
| 客户标签背景 | gradient | linear-gradient(135deg, #ecf2fe, #e8eeff) | linear-gradient(135deg, #ecf2fe, #e8eeff) |
|
||||
| 客户标签边框 | — | #c5d4f7 | #c5d4f7 |
|
||||
| 助教标签文字 | text-success | #00a870 | #00a870 |
|
||||
| 助教标签背景 | gradient | linear-gradient(135deg, #e8faf0, #e0f7ea) | linear-gradient(135deg, #e8faf0, #e0f7ea) |
|
||||
| 助教标签边框 | — | #b3e6d0 | #b3e6d0 |
|
||||
|
||||
### 列表容器
|
||||
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|
||||
|------|-----------|----------|-----------|
|
||||
| 列表 padding | p-4 | 16px | 16px |
|
||||
| 列表背景 | bg-gray-1 | #f3f3f3 | #f3f3f3 |
|
||||
|
||||
### 底部提示
|
||||
| 元素 | H5 Tailwind | H5 CSS px | 小程序 WXSS |
|
||||
|------|-----------|----------|-----------|
|
||||
| 提示字号 | text-xs | 12px | 12px |
|
||||
| 提示颜色 | text-gray-5 | #c5c5c5 | #c5c5c5 |
|
||||
| 提示 padding | py-8 pb-16 | 32px 0 64px | 20px 0 40px |
|
||||
|
||||
---
|
||||
|
||||
## 关键转换规则
|
||||
|
||||
### 1. Tailwind spacing → px
|
||||
```
|
||||
p-4 (padding: 1rem) → 16px
|
||||
p-1 (padding: 0.25rem) → 4px
|
||||
mb-3 (margin-bottom: 0.75rem) → 12px
|
||||
space-y-3 (gap: 0.75rem) → 12px
|
||||
```
|
||||
|
||||
### 2. Tailwind 字号 → px
|
||||
```
|
||||
text-sm (0.875rem) → 14px
|
||||
text-xs (0.75rem) → 12px
|
||||
text-base (1rem) → 16px
|
||||
```
|
||||
|
||||
### 3. Tailwind 圆角 → px
|
||||
```
|
||||
rounded-2xl (border-radius: 1rem) → 16px
|
||||
rounded-lg (border-radius: 0.5rem) → 8px
|
||||
```
|
||||
|
||||
### 4. 行高处理
|
||||
```
|
||||
H5: leading-relaxed (1.625)
|
||||
小程序: 在外层 view 上设置 line-height: 20px
|
||||
原因: text 组件不支持直接设置 line-height
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证方法
|
||||
|
||||
### 在 412px 宽设备上对比
|
||||
1. 打开H5原型:`docs/h5_ui/pages/notes.html`
|
||||
2. 打开小程序:`pages/notes/notes`
|
||||
3. 对比以下关键点:
|
||||
- 导航栏高度是否一致
|
||||
- 卡片圆角是否一致
|
||||
- 文字大小是否一致
|
||||
- 间距是否一致
|
||||
- 颜色是否一致
|
||||
|
||||
### 常见问题排查
|
||||
| 问题 | 排查项 | 参考文档 |
|
||||
|------|--------|--------|
|
||||
| 文字高度不对 | 检查 line-height 是否在外层 view 上设置 | 附录B.2.3 |
|
||||
| 间距不对 | 检查 padding/margin/gap 数值 | 附录A |
|
||||
| 颜色不对 | 检查色值是否与附录C一致 | 附录C |
|
||||
| 圆角不对 | 检查 border-radius 数值 | 附录A.5 |
|
||||
| 阴影不对 | 检查 box-shadow 参数 | 桥文档 §9.2 |
|
||||
|
||||
---
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `notes.wxml` - 页面结构(已更新icon尺寸)
|
||||
- `notes.wxss` - 页面样式(已调整为px单位)
|
||||
- `notes.ts` - 页面逻辑(无需修改)
|
||||
- `notes.json` - 页面配置(无需修改)
|
||||
- `MIGRATION_NOTES.md` - 迁移说明(本文件)
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 桥文档:`docs/miniprogram-dev/h5-to-mp-bridge-v3.md`
|
||||
- 附录A:`docs/miniprogram-dev/New/appendix_a_spacing_and_sizing_dictionary_v3.md`
|
||||
- 附录B:`docs/miniprogram-dev/New/appendix_b_typography_and_text_dictionary_v3.md`
|
||||
- 附录C:`docs/miniprogram-dev/New/appendix_c_color_dictionary_v3.md`
|
||||
- H5原型:`docs/h5_ui/pages/notes.html`
|
||||
13
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.json
Normal file
13
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"navigationBarTitleText": "备注",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
76
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.ts
Normal file
76
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { mockNotes } from '../../utils/mock-data'
|
||||
import type { Note } from '../../utils/mock-data'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
|
||||
/** 带展示时间的备注项 */
|
||||
interface NoteDisplay extends Note {
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
notes: [] as NoteDisplay[],
|
||||
/** 系统状态栏高度(px),用于自定义导航栏顶部偏移 */
|
||||
statusBarHeight: 20,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight: sysInfo.statusBarHeight || 20 })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用 GET /api/xcx/notes
|
||||
try {
|
||||
const notes: NoteDisplay[] = mockNotes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
this.setData({
|
||||
pageState: notes.length > 0 ? 'normal' : 'empty',
|
||||
notes,
|
||||
})
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
}, 400)
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 错误态重试 */
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 删除备注 */
|
||||
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
|
||||
const noteId = e.currentTarget.dataset.id as string
|
||||
wx.showModal({
|
||||
title: '删除备注',
|
||||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||||
confirmColor: '#e34d59',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const notes = this.data.notes.filter((n) => n.id !== noteId)
|
||||
this.setData({ notes })
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
})
|
||||
60
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.wxml
Normal file
60
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.wxml
Normal file
@@ -0,0 +1,60 @@
|
||||
<!-- pages/notes/notes.wxml — 备注记录 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<view class="error-content">
|
||||
<text class="error-icon">😵</text>
|
||||
<text class="error-text">加载失败,请重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text class="retry-btn-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空数据态 -->
|
||||
<view class="page-empty-wrap" wx:elif="{{pageState === 'empty'}}">
|
||||
<view class="page-empty">
|
||||
<t-empty description="暂无备注记录" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<view class="page-normal" wx:elif="{{pageState === 'normal'}}">
|
||||
<!-- 备注列表 -->
|
||||
<view class="note-list">
|
||||
<view
|
||||
class="note-card"
|
||||
wx:for="{{notes}}"
|
||||
wx:key="id"
|
||||
>
|
||||
<text class="note-content">{{item.content}}</text>
|
||||
<view class="note-bottom">
|
||||
<text class="note-tag {{item.tagType === 'coach' ? 'tag-coach' : 'tag-customer'}}">{{item.tagLabel}}</text>
|
||||
<view class="note-bottom-right">
|
||||
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
|
||||
<t-icon name="delete" size="16px" color="#a6a6a6" />
|
||||
</view>
|
||||
<text class="note-time">{{item.timeLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="list-footer">
|
||||
<text class="footer-text">— 已加载全部记录 —</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
203
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.wxss
Normal file
203
apps/DEMO-miniprogram/miniprogram/pages/notes/notes.wxss
Normal file
@@ -0,0 +1,203 @@
|
||||
/* pages/notes/notes.wxss — 备注记录页样式 */
|
||||
|
||||
/* ========== 全局设置 ========== */
|
||||
page {
|
||||
background-color: #f3f3f3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* ========== 自定义导航栏 ========== */
|
||||
.safe-area-top {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.custom-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nav-back--hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* ========== 页面背景 ========== */
|
||||
.page-loading,
|
||||
.page-empty-wrap,
|
||||
.page-normal,
|
||||
.page-error {
|
||||
min-height: 100vh;
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
/* ========== 加载态 / 空态 ========== */
|
||||
.page-loading,
|
||||
.page-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ========== 错误态 ========== */
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 54px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 14px;
|
||||
color: #777777;
|
||||
margin-bottom: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 10px 30px;
|
||||
background-color: #0052d9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.retry-btn--hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.retry-btn-text {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* ========== 备注列表 ========== */
|
||||
.note-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.note-card + .note-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ========== 备注卡片 ========== */
|
||||
.note-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.note-content {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #242424;
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.note-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note-bottom-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== 删除按钮 ========== */
|
||||
.note-delete-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-delete-btn--hover {
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
/* ========== 标签样式 ========== */
|
||||
.note-tag {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.tag-customer {
|
||||
background: linear-gradient(135deg, #ecf2fe, #e8eeff);
|
||||
color: #0052d9;
|
||||
border-color: #c5d4f7;
|
||||
}
|
||||
|
||||
.tag-coach {
|
||||
background: linear-gradient(135deg, #e8faf0, #e0f7ea);
|
||||
color: #00a870;
|
||||
border-color: #b3e6d0;
|
||||
}
|
||||
|
||||
.note-time {
|
||||
font-size: 12px;
|
||||
color: #a6a6a6;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* ========== 底部提示 ========== */
|
||||
.list-footer {
|
||||
text-align: center;
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #c5c5c5;
|
||||
line-height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"navigationBarTitleText": "业绩明细",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mockPerformanceRecords } from '../../utils/mock-data'
|
||||
import type { PerformanceRecord } from '../../utils/mock-data'
|
||||
import { nameToAvatarColor } from '../../utils/avatar-color'
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
import { formatHours } from '../../utils/time'
|
||||
|
||||
/** 按日期分组后的展示结构 */
|
||||
interface DateGroup {
|
||||
date: string
|
||||
totalHours: number
|
||||
totalIncome: number
|
||||
totalHoursLabel: string
|
||||
totalIncomeLabel: string
|
||||
records: RecordItem[]
|
||||
}
|
||||
|
||||
interface RecordItem {
|
||||
id: string
|
||||
customerName: string
|
||||
avatarChar: string
|
||||
avatarColor: string
|
||||
timeRange: string
|
||||
hours: number // 折算后课时(小时,number)
|
||||
hoursRaw?: number // 折算前课时(小时,number,可选)
|
||||
courseType: string
|
||||
courseTypeClass: string
|
||||
location: string
|
||||
income: number // 收入(元,整数)
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
|
||||
/** Banner */
|
||||
coachName: '小燕',
|
||||
coachLevel: '星级',
|
||||
storeName: '球会名称店',
|
||||
|
||||
/** 月份切换 */
|
||||
currentYear: 2026,
|
||||
currentMonth: 2,
|
||||
monthLabel: '2026年2月',
|
||||
canGoPrev: true,
|
||||
canGoNext: false,
|
||||
|
||||
/** 统计概览 */
|
||||
totalCount: 0,
|
||||
totalHours: 0,
|
||||
totalIncome: 0,
|
||||
totalCountLabel: '--',
|
||||
totalHoursLabel: '--',
|
||||
totalHoursRawLabel: '',
|
||||
totalIncomeLabel: '--',
|
||||
|
||||
/** 按日期分组的记录 */
|
||||
dateGroups: [] as DateGroup[],
|
||||
|
||||
/** 所有记录(用于筛选) */
|
||||
allRecords: [] as PerformanceRecord[],
|
||||
|
||||
/** 分页 */
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadData(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore) return
|
||||
this.setData({ page: this.data.page + 1 })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData(cb?: () => void) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// TODO: 替换为真实 API,按月份请求
|
||||
const allRecords = mockPerformanceRecords
|
||||
|
||||
const dateGroups: DateGroup[] = [
|
||||
{
|
||||
date: '2月7日',
|
||||
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
|
||||
totalIncome: 510,
|
||||
records: [
|
||||
{ id: 'r1', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
{ id: 'r2', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '16:00-18:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
|
||||
{ id: 'r3', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月6日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r4', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r5', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '15:30-17:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月5日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 320,
|
||||
records: [
|
||||
{ id: 'r6', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r7', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '14:00-16:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月4日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r8', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '19:00-21:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r9', customerName: '吴女士', avatarChar: '吴', avatarColor: nameToAvatarColor('吴'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月3日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r10', customerName: '郑先生', avatarChar: '郑', avatarColor: nameToAvatarColor('郑'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
|
||||
{ id: 'r11', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月2日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r12', customerName: '林先生', avatarChar: '林', avatarColor: nameToAvatarColor('林'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
|
||||
{ id: 'r13', customerName: '何女士', avatarChar: '何', avatarColor: nameToAvatarColor('何'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP3号房', income: 190 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月1日',
|
||||
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
|
||||
totalIncome: 510,
|
||||
records: [
|
||||
{ id: 'r14', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:30-22:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
{ id: 'r15', customerName: '马先生', avatarChar: '马', avatarColor: nameToAvatarColor('马'), timeRange: '16:00-18:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '8号台', income: 160 },
|
||||
{ id: 'r16', customerName: '罗女士', avatarChar: '罗', avatarColor: nameToAvatarColor('罗'), timeRange: '12:30-14:30', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r17', customerName: '梁先生', avatarChar: '梁', avatarColor: nameToAvatarColor('梁'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r18', customerName: '宋女士', avatarChar: '宋', avatarColor: nameToAvatarColor('宋'), timeRange: '8:30-10:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
{ id: 'r19', customerName: '谢先生', avatarChar: '谢', avatarColor: nameToAvatarColor('谢'), timeRange: '7:00-8:00', hours: 1.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月31日',
|
||||
totalHours: 5.5, totalHoursLabel: formatHours(5.5), totalIncome: 470, totalIncomeLabel: formatMoney(470),
|
||||
records: [
|
||||
{ id: 'r20', customerName: '韩女士', avatarChar: '韩', avatarColor: nameToAvatarColor('韩'), timeRange: '21:00-23:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
|
||||
{ id: 'r21', customerName: '唐先生', avatarChar: '唐', avatarColor: nameToAvatarColor('唐'), timeRange: '18:30-20:30', hours: 2.0, hoursRaw: 2.5, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r22', customerName: '冯女士', avatarChar: '冯', avatarColor: nameToAvatarColor('冯'), timeRange: '14:00-16:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月30日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r23', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:30-21:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r24', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月29日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 320,
|
||||
records: [
|
||||
{ id: 'r25', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r26', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月28日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r27', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '19:00-21:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
|
||||
{ id: 'r28', customerName: '董先生', avatarChar: '董', avatarColor: nameToAvatarColor('董'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月27日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r29', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '20:00-22:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r30', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:30', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
this.setData({
|
||||
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
|
||||
allRecords,
|
||||
dateGroups,
|
||||
totalCount: 32,
|
||||
totalHours: 59.0,
|
||||
totalIncome: 4720,
|
||||
totalCountLabel: formatCount(32, '笔'),
|
||||
totalHoursLabel: formatHours(59.0),
|
||||
totalHoursRawLabel: formatHours(63.5),
|
||||
totalIncomeLabel: formatMoney(4720),
|
||||
hasMore: false,
|
||||
})
|
||||
} catch (_err) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
|
||||
cb?.()
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onNavBack() {
|
||||
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/task-list/task-list' }) })
|
||||
},
|
||||
|
||||
/** 切换月份 */
|
||||
switchMonth(e: WechatMiniprogram.TouchEvent) {
|
||||
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
|
||||
let { currentYear, currentMonth } = this.data
|
||||
|
||||
if (direction === 'prev') {
|
||||
currentMonth--
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12
|
||||
currentYear--
|
||||
}
|
||||
} else {
|
||||
currentMonth++
|
||||
if (currentMonth > 12) {
|
||||
currentMonth = 1
|
||||
currentYear++
|
||||
}
|
||||
}
|
||||
|
||||
// 不能超过当前月
|
||||
const now = new Date()
|
||||
const nowYear = now.getFullYear()
|
||||
const nowMonth = now.getMonth() + 1
|
||||
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
|
||||
|
||||
this.setData({
|
||||
currentYear,
|
||||
currentMonth,
|
||||
monthLabel: `${currentYear}年${currentMonth}月`,
|
||||
canGoNext,
|
||||
canGoPrev: true,
|
||||
})
|
||||
|
||||
this.loadData()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
<wxs src="../../utils/format.wxs" module="fmt" />
|
||||
<!-- toast 加载浮层(fixed,不销毁内容,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误态(全屏,仅在 error 时展示) -->
|
||||
<view class="page-error" wx:if="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败,请点击重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text>重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容(始终挂载,不随 loading 销毁) -->
|
||||
<block wx:if="{{pageState !== 'error'}}">
|
||||
<!-- Banner 区域(复用助教详情样式) -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-overlay">
|
||||
<view class="coach-header">
|
||||
<view class="avatar-box">
|
||||
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="info-middle">
|
||||
<view class="name-row">
|
||||
<text class="coach-name">{{coachName}}</text>
|
||||
<coach-level-tag level="{{coachLevel}}" />
|
||||
</view>
|
||||
<view class="skill-row">
|
||||
<text class="store-name-text">{{storeName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 月份切换 -->
|
||||
<view class="month-switcher">
|
||||
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="prev" bindtap="switchMonth">
|
||||
<t-icon name="chevron-left" size="32rpx" />
|
||||
</view>
|
||||
<text class="month-label">{{monthLabel}}</text>
|
||||
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="next" bindtap="switchMonth">
|
||||
<t-icon name="chevron-right" size="32rpx" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<view class="stats-overview">
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">总记录</text>
|
||||
<text class="stat-value">{{totalCountLabel}}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">总业绩时长</text>
|
||||
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
|
||||
<text class="stat-hint">预估</text>
|
||||
<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">收入</text>
|
||||
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
|
||||
<text class="stat-hint">预估</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
|
||||
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
|
||||
<text class="empty-text">暂无数据</text>
|
||||
</view>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<view class="records-container" wx:elif="{{pageState === 'normal' || pageState === 'loading'}}">
|
||||
<view class="records-card">
|
||||
<block wx:for="{{dateGroups}}" wx:key="date">
|
||||
<!-- 日期分隔线 -->
|
||||
<view class="date-divider">
|
||||
<text decode class="dd-date">{{item.date}} —</text>
|
||||
<text decode class="dd-stats" wx:if="{{item.totalHoursLabel}}">{{item.totalHoursLabel}} · 预估 {{item.totalIncomeLabel}} </text>
|
||||
<view class="dd-line"></view>
|
||||
</view>
|
||||
|
||||
<!-- 该日期下的记录 -->
|
||||
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="id"
|
||||
hover-class="record-item--hover">
|
||||
<view class="record-avatar avatar-{{rec.avatarColor}}">
|
||||
<text>{{rec.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<view class="record-top">
|
||||
<view class="record-name-time">
|
||||
<text class="record-name">{{rec.customerName}}</text>
|
||||
<text class="record-time">{{rec.timeRange}}</text>
|
||||
</view>
|
||||
<view class="record-hours-wrap">
|
||||
<text class="record-hours">{{fmt.hours(rec.hours)}}</text>
|
||||
<text class="record-hours-deduct" wx:if="{{rec.hoursRaw}}">(折后 {{fmt.hours(rec.hoursRaw)}})</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-bottom">
|
||||
<view class="record-tags">
|
||||
<text class="course-tag {{rec.courseTypeClass}}">{{rec.courseType}}</text>
|
||||
<text class="record-location">{{rec.location}}</text>
|
||||
</view>
|
||||
<text class="record-income">我的预估收入 <text class="record-income-val">{{fmt.money(rec.income)}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 列表底部提示 -->
|
||||
<view class="list-end-hint">
|
||||
<text>— 已加载全部记录 —</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
|
||||
<dev-fab />
|
||||
@@ -0,0 +1,492 @@
|
||||
/* pages/performance-records/performance-records.wxss */
|
||||
|
||||
page {
|
||||
background-color: #f3f3f3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 加载态 / 空态 / 错误态
|
||||
* ============================================ */
|
||||
|
||||
/* toast 风格加载(浮在页面中央,不全屏变白) */
|
||||
.toast-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-loading-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(36, 36, 36, 0.75);
|
||||
border-radius: 24rpx;
|
||||
padding: 36rpx 48rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast-loading-text {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.page-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 28rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background: #0052d9;
|
||||
color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.retry-btn--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* Banner(复用助教详情)
|
||||
* ============================================ */
|
||||
.banner-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.banner-bg-img {
|
||||
position: absolute;
|
||||
top: -50rpx;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 36rpx 40rpx;
|
||||
}
|
||||
|
||||
.coach-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
width: 98rpx;
|
||||
height: 98rpx;
|
||||
border-radius: 32rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-middle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.coach-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 助教等级 tag — 遵循 VI 规范(§5 助教等级配色) */
|
||||
.coach-level-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.coach-level-star { color: #fbbf24; background: #fffef0; }
|
||||
.coach-level-senior { color: #e91e63; background: #ffe6e8; }
|
||||
.coach-level-middle { color: #ed7b2f; background: #fff3e6; }
|
||||
.coach-level-junior { color: #0052d9; background: #ecf2fe; }
|
||||
|
||||
/* 球会名称 — 纯文字,无背景 */
|
||||
.store-name-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 月份切换
|
||||
* ============================================ */
|
||||
.month-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 48rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.month-btn {
|
||||
padding: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.month-btn-disabled {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.month-btn--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 统计概览
|
||||
* ============================================ */
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-primary {
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
.stat-sub-hint {
|
||||
font-size: 20rpx;
|
||||
color: #c5c5c5;
|
||||
margin-top: 2rpx;
|
||||
line-height: 26rpx;
|
||||
}
|
||||
|
||||
.stat-hours-raw {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
margin-top: 2rpx;
|
||||
line-height: 26rpx;
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
font-size: 20rpx;
|
||||
color: #ed7b2f;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2rpx;
|
||||
height: 80rpx;
|
||||
background: #eeeeee;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 记录列表
|
||||
* ============================================ */
|
||||
.records-container {
|
||||
padding: 24rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.records-card {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 32rpx 8rpx;
|
||||
}
|
||||
|
||||
.dd-date {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.dd-line {
|
||||
flex: 1;
|
||||
height: 2rpx;
|
||||
background: #dcdcdc;
|
||||
}
|
||||
|
||||
.dd-stats {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
|
||||
.record-item--hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.record-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-from-blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
|
||||
.avatar-from-pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
|
||||
.avatar-from-teal { background: linear-gradient(135deg, #2dd4bf, #10b981); }
|
||||
.avatar-from-green { background: linear-gradient(135deg, #4ade80, #14b8a6); }
|
||||
.avatar-from-orange { background: linear-gradient(135deg, #fb923c, #f59e0b); }
|
||||
.avatar-from-purple { background: linear-gradient(135deg, #c084fc, #8b5cf6); }
|
||||
.avatar-from-violet { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
|
||||
.avatar-from-amber { background: linear-gradient(135deg, #fbbf24, #eab308); }
|
||||
.avatar-from-sky { background: linear-gradient(135deg, #38bdf8, #0ea5e9); }
|
||||
.avatar-from-lime { background: linear-gradient(135deg, #a3e635, #65a30d); }
|
||||
.avatar-from-rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
|
||||
.avatar-from-fuchsia{ background: linear-gradient(135deg, #e879f9, #a21caf); }
|
||||
.avatar-from-slate { background: linear-gradient(135deg, #94a3b8, #475569); }
|
||||
.avatar-from-indigo { background: linear-gradient(135deg, #818cf8, #4338ca); }
|
||||
.avatar-from-cyan { background: linear-gradient(135deg, #22d3ee, #0891b2); }
|
||||
.avatar-from-yellow { background: linear-gradient(135deg, #facc15, #ca8a04); }
|
||||
|
||||
/* 内容区 */
|
||||
.record-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.record-name-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
flex-shrink: 0;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-hours-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-hours {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.record-hours-deduct {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.record-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.tag-basic {
|
||||
background: #ecfdf5;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.tag-vip {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.tag-tip {
|
||||
background: #fffbeb;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.record-location {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income {
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
flex-shrink: 0;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income-val {
|
||||
font-weight: 500;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
/* 列表底部提示 */
|
||||
.list-end-hint {
|
||||
text-align: center;
|
||||
padding: 24rpx 0 28rpx;
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
# Performance 页面迁移说明
|
||||
|
||||
## 迁移版本
|
||||
- **H5 原型基准**:`docs/h5_ui/pages/performance.html`
|
||||
- **桥文档版本**:v3.0
|
||||
- **迁移日期**:2026-03-15
|
||||
- **更新日期**:2026-03-15(Banner背景改用灵活的SVG实现)
|
||||
|
||||
---
|
||||
|
||||
## 样式调整清单
|
||||
|
||||
### 1. 单位转换规则
|
||||
遵循桥文档 §2.3 的混合单位策略:
|
||||
- **主要使用 `rpx`**:页面宽度、横向布局、卡片尺寸、间距
|
||||
- **使用 `px`**:导航栏高度(44px)、图标点击区域
|
||||
- **换算基准**:H5 CSS px → 小程序 rpx(750宽基准,1px = 1.8204rpx)
|
||||
|
||||
### 2. 自定义导航栏(.custom-nav)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| height | 44px | 44px | 标准导航栏高度(使用px) |
|
||||
| font-size | text-base (16px) | 32rpx | 标题字号 |
|
||||
| icon size | w-5 h-5 (20px) | 24px | 返回按钮图标 |
|
||||
|
||||
### 3. Banner 区域(.banner-section)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 背景方案 | CSS 渐变 | SVG 图片 | 使用 `/assets/images/banner-bg-combined.svg` |
|
||||
| height | - | 480rpx | Banner 高度(可通过 WXSS 调整) |
|
||||
| padding | px-5 pt-2 pb-2 (20px 8px) | 24rpx 40rpx 32rpx | 内容区内边距 |
|
||||
|
||||
#### Banner SVG 背景实现(灵活可调)
|
||||
- **方案**:SVG 做渐变底图(与 task-detail/task-list 保持一致)
|
||||
- **原因**:SVG pattern 在小程序 image 组件中不渲染
|
||||
- **WXML 实现**:
|
||||
```wxml
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-combined.svg" mode="aspectFill" />
|
||||
<view class="banner-content">
|
||||
<!-- 内容 -->
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
- **WXSS 实现**(可灵活调整):
|
||||
```wxss
|
||||
.banner-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 480rpx; /* 调整此值改变 Banner 高度 */
|
||||
}
|
||||
|
||||
.banner-bg-img {
|
||||
position: absolute;
|
||||
top: 0; /* 调整此值改变背景垂直位置 */
|
||||
left: 0; /* 调整此值改变背景水平位置 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 24rpx 40rpx 32rpx; /* 调整此值改变内容区内边距 */
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
```
|
||||
- **调整方式**:
|
||||
- 修改 `.banner-section` 的 `height` 调整 Banner 高度
|
||||
- 修改 `.banner-bg-img` 的 `top/left` 调整背景位置
|
||||
- 修改 `.banner-bg-img` 的 `width/height` 调整背景缩放
|
||||
- 修改 `.banner-content` 的 `padding` 调整内容区域
|
||||
|
||||
#### 个人信息卡片
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| avatar size | w-14 h-14 (56px) | 112rpx | 头像尺寸 |
|
||||
| avatar radius | rounded-2xl (16px) | 24rpx | 头像圆角 |
|
||||
| name font-size | text-xl (20px) | 40rpx | 姓名字号 |
|
||||
| role tag padding | px-2 py-0.5 (8px 2px) | 4rpx 16rpx | 角色标签内边距 |
|
||||
|
||||
#### 收入概览卡片
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| grid gap | gap-3 (12px) | 24rpx | 卡片间距 |
|
||||
| card padding | p-4 (16px) | 24rpx | 卡片内边距 |
|
||||
| card radius | rounded-xl (12px) | 24rpx | 卡片圆角 |
|
||||
| label font-size | text-xs (12px) | 22rpx | 标签字号 |
|
||||
| value font-size | text-2xl (24px) | 44rpx | 数值字号 |
|
||||
|
||||
### 4. Section 卡片(.section-card)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| border-radius | rounded-2xl (16px) | 32rpx | 卡片圆角 |
|
||||
| padding | p-4 (16px) | 32rpx | 卡片内边距 |
|
||||
| margin | m-4 (16px) | 24rpx 24rpx 0 | 卡片外边距 |
|
||||
| box-shadow | shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | 阴影 |
|
||||
|
||||
### 5. 收入档位卡片(.tier-card)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| padding | p-3 (12px) | 24rpx | 内边距 |
|
||||
| border-radius | rounded-xl (12px) | 24rpx | 圆角 |
|
||||
| border | 1px solid | 2rpx solid | 边框 |
|
||||
| emoji size | text-2xl (24px) | 48rpx | 表情图标 |
|
||||
| rate value | text-lg (18px) | 36rpx | 费率数值 |
|
||||
| rate unit | text-xs (12px) | 22rpx | 单位字号 |
|
||||
| rate desc | text-[10px] | 20rpx | 描述字号 |
|
||||
|
||||
#### 档位徽章
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| top | -8px | -16rpx | 顶部偏移 |
|
||||
| padding | 2px 10px | 4rpx 20rpx | 内边距 |
|
||||
| font-size | 10px | 20rpx | 字号 |
|
||||
|
||||
### 6. 升级提示(.upgrade-hint)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| padding | p-3 (12px) | 24rpx | 内边距 |
|
||||
| border-radius | rounded-xl (12px) | 24rpx | 圆角 |
|
||||
| emoji size | text-lg (18px) | 36rpx | 表情大小 |
|
||||
| label font-size | text-xs (12px) | 22rpx | 标签字号 |
|
||||
| hours font-size | text-sm (14px) | 26rpx | 小时数字号 |
|
||||
| bonus value | text-lg (18px) | 36rpx | 奖金数值 |
|
||||
|
||||
### 7. 业绩明细列表(.income-list)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| icon box size | w-8 h-8 (32px) | 64rpx | 图标容器 |
|
||||
| icon box radius | rounded-lg (8px) | 16rpx | 图标圆角 |
|
||||
| label font-size | text-sm (14px) | 26rpx | 标签字号 |
|
||||
| desc font-size | text-[10px] | 20rpx | 描述字号 |
|
||||
| value font-size | text-base (16px) | 30rpx | 数值字号 |
|
||||
| total value | text-xl (20px) | 40rpx | 合计数值 |
|
||||
|
||||
### 8. 服务记录(.service-records-section)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| avatar size | w-[38px] h-[38px] | 76rpx | 头像尺寸 |
|
||||
| avatar radius | rounded-lg (8px) | 16rpx | 头像圆角 |
|
||||
| name font-size | text-sm (14px) | 26rpx | 姓名字号 |
|
||||
| time font-size | text-xs (12px) | 22rpx | 时间字号 |
|
||||
| hours font-size | text-sm (14px) | 26rpx | 小时数字号 |
|
||||
| tag padding | px-1.5 py-px (6px 1px) | 2rpx 12rpx | 标签内边距 |
|
||||
| tag font-size | text-[11px] | 22rpx | 标签字号 |
|
||||
|
||||
#### 日期分隔线
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| date font-size | 11px | 22rpx | 日期字号 |
|
||||
| line height | 1px | 2rpx | 分隔线高度 |
|
||||
| padding | 14px 16px 4px | 20rpx 0 8rpx | 内边距 |
|
||||
|
||||
### 9. 客户列表(.customer-list)
|
||||
|
||||
| 属性 | H5 值 | 小程序值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| avatar size | w-[38px] h-[38px] | 76rpx | 头像尺寸 |
|
||||
| avatar radius | rounded-lg (8px) | 16rpx | 头像圆角 |
|
||||
| name font-size | text-sm (14px) | 26rpx | 姓名字号 |
|
||||
| detail font-size | text-xs (12px) | 22rpx | 详情字号 |
|
||||
| icon size | - | 24px | 右侧箭头图标 |
|
||||
|
||||
### 10. 颜色映射(附录C)
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 主品牌色 | #0052d9 | primary |
|
||||
| 成功色 | #00a870 | success |
|
||||
| 背景色 | #f3f3f3 | gray-1 |
|
||||
| 正文色 | #242424 | gray-13 |
|
||||
| 次级文字 | #a6a6a6 | gray-6 |
|
||||
| 三级文字 | #8b8b8b | gray-7 |
|
||||
| 四级文字 | #c5c5c5 | gray-5 |
|
||||
| 分割线 | #f3f3f3 | gray-1 |
|
||||
|
||||
### 11. 渐变色系统
|
||||
|
||||
#### 头像渐变(8种)
|
||||
- `from-blue`: #60a5fa → #6366f1
|
||||
- `from-pink`: #f472b6 → #f43f5e
|
||||
- `from-teal`: #2dd4bf → #10b981
|
||||
- `from-green`: #4ade80 → #14b8a6
|
||||
- `from-orange`: #fb923c → #f59e0b
|
||||
- `from-purple`: #c084fc → #8b5cf6
|
||||
- `from-violet`: #a78bfa → #7c3aed
|
||||
- `from-amber`: #fbbf24 → #eab308
|
||||
|
||||
#### 档位卡片渐变
|
||||
- 当前档位:#f0fdf4 → #dcfce7(绿色系)
|
||||
- 下一阶段:#fefce8 → #fef9c3(黄色系)
|
||||
- 升级提示:#eff6ff → #eef2ff(蓝色系)
|
||||
- 奖金按钮:#fbbf24 → #f97316(橙色系)
|
||||
|
||||
---
|
||||
|
||||
## 关键调整点
|
||||
|
||||
### 1. 全局 line-height 设置
|
||||
```wxss
|
||||
page {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
```
|
||||
**原因**:微信小程序 `<text>` 组件不能直接设置 `line-height`,必须在外层 `<view>` 设置(附录B.2.3)
|
||||
|
||||
### 2. 单位统一为 rpx(除特殊情况)
|
||||
- 主要尺寸使用 `rpx`,确保不同设备自适应
|
||||
- 导航栏高度、图标点击区域使用 `px`
|
||||
- 换算公式:1px = 1.8204rpx(750宽基准)
|
||||
|
||||
### 3. 移除 CSS 变量依赖
|
||||
- 原:`var(--color-gray-2, #eeeeee)`
|
||||
- 新:直接使用 `#eeeeee`
|
||||
- 原因:确保样式独立,不依赖全局变量
|
||||
|
||||
### 4. Icon 尺寸调整
|
||||
- 导航栏返回按钮:`size="24px"`(标准点击区域)
|
||||
- 列表箭头图标:`size="24px"`
|
||||
- 加载/错误图标:`size="40px"`
|
||||
|
||||
### 5. 复杂背景还原
|
||||
- **Banner 背景**:使用 SVG 图片 `/assets/images/banner-bg-combined.svg`(与 task-detail/task-list 保持一致)
|
||||
- 原因:SVG pattern 在小程序 image 组件中不渲染
|
||||
- **灵活可调**:通过 WXSS 可调整 Banner 高度、背景位置、内容区内边距
|
||||
- 档位卡片使用双色渐变背景
|
||||
- 头像使用 8 种预设渐变色
|
||||
- 升级提示使用渐变背景 + 渐变按钮
|
||||
|
||||
### 6. 数值字体特性
|
||||
```wxss
|
||||
.perf-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
确保数字等宽对齐
|
||||
|
||||
---
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [x] 导航栏高度与H5对齐(44px)
|
||||
- [x] Banner SVG背景与H5对齐
|
||||
- [x] Banner 高度可通过 WXSS 调整(480rpx)
|
||||
- [x] Banner 背景位置可通过 WXSS 调整
|
||||
- [x] 个人信息卡片尺寸与H5对齐
|
||||
- [x] 收入概览卡片样式与H5对齐
|
||||
- [x] 档位卡片渐变背景与H5对齐
|
||||
- [x] 档位徽章位置与H5对齐
|
||||
- [x] 升级提示样式与H5对齐
|
||||
- [x] 业绩明细列表与H5对齐
|
||||
- [x] 服务记录样式与H5对齐
|
||||
- [x] 日期分隔线与H5对齐
|
||||
- [x] 客户列表样式与H5对齐
|
||||
- [x] 头像渐变色与H5对齐
|
||||
- [x] 所有字号与H5对齐
|
||||
- [x] 所有间距与H5对齐
|
||||
- [x] 所有圆角与H5对齐
|
||||
- [x] 所有颜色与H5对齐
|
||||
- [x] 行高设置正确(在view上设置)
|
||||
- [x] Banner背景使用灵活的SVG实现(与task-detail保持一致)
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **动画效果**:H5 中的 `bonus-highlight` 闪烁动画未实现(需要 CSS animation)
|
||||
2. **backdrop-filter**:收入卡片的 `backdrop-blur` 效果在部分安卓设备可能不支持
|
||||
3. **SVG兼容性**:banner-bg-combined.svg 需要确保存在于 `/assets/images/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 后续维护
|
||||
|
||||
- 若真机测试发现尺寸偏差,优先检查 line-height 设置
|
||||
- 若需要调整 Banner 高度,修改 `.banner-section` 的 `height` 属性
|
||||
- 若需要调整 Banner 背景位置,修改 `.banner-bg-img` 的 `top/left` 属性
|
||||
- 若需要调整间距,参考附录A的spacing表
|
||||
- 若需要调整字号,参考附录B的字体表
|
||||
- 颜色调整参考附录C的颜色字典
|
||||
- Banner背景问题参考task-detail页面的实现方式
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"navigationBarTitleText": "业绩详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"metric-card": "/components/metric-card/metric-card",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// TODO: 联调时替换为真实 API 调用
|
||||
import { initPageAiColor } from '../../utils/ai-color-manager'
|
||||
|
||||
/** 业绩明细项(本月/上月) */
|
||||
interface IncomeItem {
|
||||
icon: string
|
||||
label: string
|
||||
desc: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 服务记录(按日期分组后的展示结构) */
|
||||
interface ServiceRecord {
|
||||
customerName: string
|
||||
avatarChar: string
|
||||
avatarColor: string
|
||||
timeRange: string
|
||||
hours: string
|
||||
courseType: string
|
||||
courseTypeClass: string
|
||||
location: string
|
||||
income: string
|
||||
}
|
||||
|
||||
interface DateGroup {
|
||||
date: string
|
||||
totalHours?: string
|
||||
totalIncome?: string
|
||||
records: ServiceRecord[]
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
|
||||
/** Banner 数据 */
|
||||
coachName: '小燕',
|
||||
coachRole: '助教',
|
||||
storeName: '广州朗朗桌球',
|
||||
monthlyIncome: '¥6,206',
|
||||
lastMonthIncome: '¥16,880',
|
||||
|
||||
/** 收入档位 */
|
||||
currentTier: {
|
||||
basicRate: 80,
|
||||
incentiveRate: 95,
|
||||
},
|
||||
nextTier: {
|
||||
basicRate: 90,
|
||||
incentiveRate: 114,
|
||||
},
|
||||
upgradeHoursNeeded: 15,
|
||||
upgradeBonus: 800,
|
||||
|
||||
/** 本月业绩明细 */
|
||||
incomeItems: [] as IncomeItem[],
|
||||
monthlyTotal: '¥6,950.5',
|
||||
|
||||
/** 服务记录 */
|
||||
thisMonthRecords: [] as DateGroup[],
|
||||
thisMonthRecordsExpanded: false,
|
||||
/** 默认显示前 N 条日期组 */
|
||||
visibleRecordGroups: 2,
|
||||
|
||||
/** 新客列表 */
|
||||
newCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; lastService: string; count: number }>,
|
||||
newCustomerExpanded: false,
|
||||
|
||||
/** 常客列表 */
|
||||
regularCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; hours: number; income: string; count: number }>,
|
||||
regularCustomerExpanded: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
// 初始化 AI 图标配色(蓝色 - 数据分析感)
|
||||
const { aiColor } = initPageAiColor('performance')
|
||||
this.setData({ aiColor })
|
||||
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// TODO: 替换为真实 API
|
||||
const incomeItems: IncomeItem[] = [
|
||||
{ icon: '🎱', label: '基础课', desc: '80元/h × 75h', value: '¥6,000' },
|
||||
{ icon: '⭐', label: '激励课', desc: '95.05元/h × 10h', value: '¥950.5' },
|
||||
{ icon: '💰', label: '充值激励', desc: '客户充值返佣', value: '¥500' },
|
||||
{ icon: '🏆', label: 'TOP3 销冠奖', desc: '全店业绩前三名奖励', value: '继续努力' },
|
||||
]
|
||||
|
||||
const gradients = [
|
||||
'blue', 'pink', 'teal', 'green',
|
||||
'orange', 'purple', 'violet', 'amber',
|
||||
]
|
||||
|
||||
// 模拟服务记录按日期分组
|
||||
const thisMonthRecords: DateGroup[] = [
|
||||
{
|
||||
date: '2月7日',
|
||||
totalHours: '4.0h',
|
||||
totalIncome: '¥350',
|
||||
records: [
|
||||
{ customerName: '王先生', avatarChar: '王', avatarColor: gradients[0], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: '¥160' },
|
||||
{ customerName: '李女士', avatarChar: '李', avatarColor: gradients[1], timeRange: '16:00-18:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: '¥190' },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月6日',
|
||||
totalHours: '2.0h',
|
||||
totalIncome: '¥160',
|
||||
records: [
|
||||
{ customerName: '张先生', avatarChar: '张', avatarColor: gradients[2], timeRange: '19:00-21:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: '¥160' },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月5日',
|
||||
totalHours: '4.0h',
|
||||
totalIncome: '¥320',
|
||||
records: [
|
||||
{ customerName: '陈女士', avatarChar: '陈', avatarColor: gradients[2], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
|
||||
{ customerName: '赵先生', avatarChar: '赵', avatarColor: gradients[5], timeRange: '14:00-16:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: '¥160' },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月4日',
|
||||
totalHours: '4.0h',
|
||||
totalIncome: '¥350',
|
||||
records: [
|
||||
{ customerName: '孙先生', avatarChar: '孙', avatarColor: gradients[6], timeRange: '19:00-21:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: '¥190' },
|
||||
{ customerName: '吴女士', avatarChar: '吴', avatarColor: gradients[2], timeRange: '15:00-17:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: '¥160' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const newCustomers = [
|
||||
{ name: '王先生', avatarChar: '王', avatarColor: gradients[0], lastService: '2月7日', count: 2 },
|
||||
{ name: '李女士', avatarChar: '李', avatarColor: gradients[1], lastService: '2月7日', count: 1 },
|
||||
{ name: '刘先生', avatarChar: '刘', avatarColor: gradients[4], lastService: '2月6日', count: 1 },
|
||||
{ name: '周女士', avatarChar: '周', avatarColor: gradients[3], lastService: '2月5日', count: 1 },
|
||||
{ name: '吴先生', avatarChar: '吴', avatarColor: gradients[7], lastService: '2月4日', count: 1 },
|
||||
{ name: '郑女士', avatarChar: '郑', avatarColor: gradients[5], lastService: '2月3日', count: 1 },
|
||||
{ name: '钱先生', avatarChar: '钱', avatarColor: gradients[6], lastService: '2月2日', count: 1 },
|
||||
{ name: '冯女士', avatarChar: '冯', avatarColor: gradients[2], lastService: '2月1日', count: 1 },
|
||||
]
|
||||
|
||||
const regularCustomers = [
|
||||
{ name: '张先生', avatarChar: '张', avatarColor: gradients[2], hours: 12, income: '¥960', count: 6 },
|
||||
{ name: '陈女士', avatarChar: '陈', avatarColor: gradients[2], hours: 10, income: '¥800', count: 5 },
|
||||
{ name: '赵先生', avatarChar: '赵', avatarColor: gradients[5], hours: 8, income: '¥640', count: 4 },
|
||||
{ name: '孙先生', avatarChar: '孙', avatarColor: gradients[6], hours: 6, income: '¥570', count: 3 },
|
||||
{ name: '杨女士', avatarChar: '杨', avatarColor: 'pink', hours: 6, income: '¥480', count: 3 },
|
||||
{ name: '黄先生', avatarChar: '黄', avatarColor: 'amber', hours: 4, income: '¥380', count: 2 },
|
||||
{ name: '林女士', avatarChar: '林', avatarColor: 'green', hours: 4, income: '¥320', count: 2 },
|
||||
{ name: '徐先生', avatarChar: '徐', avatarColor: 'orange', hours: 2, income: '¥190', count: 1 },
|
||||
]
|
||||
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
incomeItems,
|
||||
thisMonthRecords,
|
||||
newCustomers,
|
||||
regularCustomers,
|
||||
})
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 加载失败重试 */
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 自定义导航栏返回 */
|
||||
onNavBack() {
|
||||
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/task-list/task-list' }) })
|
||||
},
|
||||
|
||||
/** 展开/收起本月服务记录 */
|
||||
toggleThisMonthRecords() {
|
||||
this.setData({ thisMonthRecordsExpanded: !this.data.thisMonthRecordsExpanded })
|
||||
},
|
||||
|
||||
/** 查看全部 → 跳转业绩明细 */
|
||||
goToRecords() {
|
||||
wx.navigateTo({ url: '/pages/performance-records/performance-records' })
|
||||
},
|
||||
|
||||
/** 展开/收起新客列表 */
|
||||
toggleNewCustomer() {
|
||||
this.setData({ newCustomerExpanded: !this.data.newCustomerExpanded })
|
||||
},
|
||||
|
||||
/** 展开/收起常客列表 */
|
||||
toggleRegularCustomer() {
|
||||
this.setData({ regularCustomerExpanded: !this.data.regularCustomerExpanded })
|
||||
},
|
||||
|
||||
/** 点击客户卡片 → 跳转任务详情 */
|
||||
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const { name } = e.currentTarget.dataset
|
||||
wx.navigateTo({
|
||||
url: `/pages/task-detail/task-detail?customerName=${name}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 点击服务记录 → 跳转任务详情 */
|
||||
onRecordTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const { customerName, taskId } = e.currentTarget.dataset
|
||||
wx.navigateTo({
|
||||
url: `/pages/task-detail/task-detail?customerName=${customerName}${taskId ? `&taskId=${taskId}` : ''}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 点击收入概览卡片 → 跳转业绩记录 */
|
||||
onIncomeCardTap() {
|
||||
wx.navigateTo({ url: '/pages/performance-records/performance-records' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
<!-- pages/performance/performance.wxml — 业绩总览 -->
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空数据态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-icon name="chart-bar" size="40px" color="#dcdcdc" />
|
||||
<text class="empty-text">暂无业绩数据</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="40px" color="#e34d59" />
|
||||
<text class="error-text">加载失败,请点击重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text>重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:else>
|
||||
<!-- Banner 区域 — SVG 做渐变底图 -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-blue-light-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-content">
|
||||
|
||||
|
||||
<!-- 个人信息 -->
|
||||
<view class="coach-info">
|
||||
<view class="coach-avatar">
|
||||
<image src="/assets/images/avatar-coach.png" class="avatar-img" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="coach-meta">
|
||||
<view class="coach-name-row">
|
||||
<text class="coach-name">{{coachName}}</text>
|
||||
<text class="coach-role-tag">{{coachRole}}</text>
|
||||
</view>
|
||||
<text class="coach-store">{{storeName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 核心收入数据 -->
|
||||
<view class="income-overview">
|
||||
<view class="income-card">
|
||||
<text class="income-label">本月预计收入</text>
|
||||
<text class="income-value">{{monthlyIncome}}</text>
|
||||
</view>
|
||||
<view class="income-card">
|
||||
<text class="income-label">上月收入</text>
|
||||
<text class="income-value income-highlight">{{lastMonthIncome}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入情况 -->
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<view class="title-dot dot-primary"></view>
|
||||
<text>收入情况</text>
|
||||
</view>
|
||||
|
||||
<!-- 当前档位 -->
|
||||
<view class="tier-card tier-current">
|
||||
<view class="tier-badge badge-current">当前档位</view>
|
||||
<view class="tier-row">
|
||||
<view class="tier-icon-label">
|
||||
<text class="tier-emoji">📊</text>
|
||||
<text class="tier-label tier-label-green">当前档位</text>
|
||||
</view>
|
||||
<view class="tier-rates">
|
||||
<view class="rate-item">
|
||||
<view class="rate-value-row">
|
||||
<text class="rate-value rate-green">{{currentTier.basicRate}}</text>
|
||||
<text class="rate-unit rate-green-light">元/h</text>
|
||||
</view>
|
||||
<text class="rate-desc rate-green-light">基础课到手</text>
|
||||
</view>
|
||||
<view class="rate-divider"></view>
|
||||
<view class="rate-item">
|
||||
<view class="rate-value-row">
|
||||
<text class="rate-value rate-green">{{currentTier.incentiveRate}}</text>
|
||||
<text class="rate-unit rate-green-light">元/h</text>
|
||||
</view>
|
||||
<text class="rate-desc rate-green-light">激励课到手</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 下一阶段 -->
|
||||
<view class="tier-card tier-next">
|
||||
<view class="tier-badge badge-next">下一阶段</view>
|
||||
<view class="tier-row">
|
||||
<view class="tier-icon-label">
|
||||
<text class="tier-emoji">🎯</text>
|
||||
<text class="tier-label tier-label-yellow">下一阶段</text>
|
||||
</view>
|
||||
<view class="tier-rates">
|
||||
<view class="rate-item">
|
||||
<view class="rate-value-row">
|
||||
<text class="rate-value rate-yellow">{{nextTier.basicRate}}</text>
|
||||
<text class="rate-unit rate-yellow-light">元/h</text>
|
||||
</view>
|
||||
<text class="rate-desc rate-yellow-light">基础课到手</text>
|
||||
</view>
|
||||
<view class="rate-divider rate-divider-yellow"></view>
|
||||
<view class="rate-item">
|
||||
<view class="rate-value-row">
|
||||
<text class="rate-value rate-yellow">{{nextTier.incentiveRate}}</text>
|
||||
<text class="rate-unit rate-yellow-light">元/h</text>
|
||||
</view>
|
||||
<text class="rate-desc rate-yellow-light">激励课到手</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 升级提示 -->
|
||||
<view class="upgrade-hint">
|
||||
<view class="upgrade-left">
|
||||
<text class="upgrade-emoji">⏱️</text>
|
||||
<view class="upgrade-text">
|
||||
<text class="upgrade-label">距离下一阶段</text>
|
||||
<view class="upgrade-hours">
|
||||
<text>需完成 </text>
|
||||
<text class="upgrade-hours-num">{{upgradeHoursNeeded}}</text>
|
||||
<text> 小时</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upgrade-bonus">
|
||||
<text class="bonus-label">到达即得</text>
|
||||
<text class="bonus-value">{{upgradeBonus}}元</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 本月业绩 -->
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<view class="title-dot dot-success"></view>
|
||||
<text>本月业绩 预估</text>
|
||||
</view>
|
||||
|
||||
<view class="income-list">
|
||||
<view class="income-row" wx:for="{{incomeItems}}" wx:key="label">
|
||||
<view class="income-row-left">
|
||||
<view class="income-icon-box">
|
||||
<text>{{item.icon}}</text>
|
||||
</view>
|
||||
<view class="income-info">
|
||||
<text class="income-item-label">{{item.label}}</text>
|
||||
<text class="income-item-desc">{{item.desc}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="income-item-value">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 合计 -->
|
||||
<view class="income-total">
|
||||
<text class="total-label">本月合计 预估</text>
|
||||
<text class="total-value">{{monthlyTotal}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 服务记录明细 -->
|
||||
<view class="service-records-section">
|
||||
<view class="service-records-header">
|
||||
<text class="service-records-emoji">📋</text>
|
||||
<text class="service-records-title">我的服务记录明细</text>
|
||||
</view>
|
||||
|
||||
<block wx:for="{{thisMonthRecords}}" wx:key="date" wx:if="{{thisMonthRecordsExpanded || index < visibleRecordGroups}}">
|
||||
<view class="date-divider">
|
||||
<text decode class="dd-date">{{item.date}} —</text>
|
||||
|
||||
<text decode class="dd-stats" wx:if=" {{item.totalHours}}">{{item.totalHours}} · {{item.totalIncome}} </text>
|
||||
<view class="dd-line"></view>
|
||||
</view>
|
||||
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName" bindtap="onRecordTap" data-customer-name="{{rec.customerName}}" data-task-id="{{rec.taskId}}">
|
||||
<view class="record-avatar avatar-{{rec.avatarColor}}">
|
||||
<text>{{rec.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<view class="record-top">
|
||||
<view class="record-name-time">
|
||||
<text class="record-name">{{rec.customerName}}</text>
|
||||
<text class="record-time">{{rec.timeRange}}</text>
|
||||
</view>
|
||||
<text class="record-hours">{{rec.hours}}</text>
|
||||
</view>
|
||||
<view class="record-bottom">
|
||||
<view class="record-tags">
|
||||
<text class="course-tag {{rec.courseTypeClass}}">{{rec.courseType}}</text>
|
||||
<text class="record-location">{{rec.location}}</text>
|
||||
</view>
|
||||
<text class="record-income">我的预估收入 <text class="record-income-val">{{rec.income}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view class="records-actions">
|
||||
<view class="records-toggle" hover-class="toggle-btn--hover" bindtap="toggleThisMonthRecords" wx:if="{{thisMonthRecords.length > visibleRecordGroups}}">
|
||||
<text>{{thisMonthRecordsExpanded ? '收起' : '展开更多'}}</text>
|
||||
<t-icon name="{{thisMonthRecordsExpanded ? 'chevron-up' : 'chevron-down'}}" size="24px" />
|
||||
</view>
|
||||
|
||||
<view class="records-view-all" hover-class="view-all--hover" bindtap="goToRecords">
|
||||
<text>查看全部</text>
|
||||
<t-icon name="chevron-right" size="24px" color="#0052d9" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的新客 -->
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<view class="title-dot dot-cyan"></view>
|
||||
<text>我的新客</text>
|
||||
</view>
|
||||
|
||||
<view class="customer-list">
|
||||
<view
|
||||
class="customer-item"
|
||||
hover-class="customer-item--hover"
|
||||
wx:for="{{newCustomers}}"
|
||||
wx:key="name"
|
||||
wx:if="{{newCustomerExpanded ? index < 20 : index < 5}}"
|
||||
data-name="{{item.name}}"
|
||||
bindtap="onCustomerTap"
|
||||
>
|
||||
<view class="customer-avatar avatar-{{item.avatarColor}}">
|
||||
<text>{{item.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="customer-info">
|
||||
<text class="customer-name">{{item.name}}</text>
|
||||
<text class="customer-detail">最近服务: {{item.lastService}} · {{item.count}}次</text>
|
||||
</view>
|
||||
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="toggle-btn" hover-class="toggle-btn--hover" bindtap="toggleNewCustomer" wx:if="{{newCustomers.length > 5}}">
|
||||
<text>{{newCustomerExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的常客 -->
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<view class="title-dot dot-pink"></view>
|
||||
<text>我的常客</text>
|
||||
</view>
|
||||
|
||||
<view class="customer-list">
|
||||
<view
|
||||
class="customer-item"
|
||||
hover-class="customer-item--hover"
|
||||
wx:for="{{regularCustomers}}"
|
||||
wx:key="name"
|
||||
wx:if="{{regularCustomerExpanded ? index < 20 : index < 5}}"
|
||||
data-name="{{item.name}}"
|
||||
bindtap="onCustomerTap"
|
||||
>
|
||||
<view class="customer-avatar avatar-{{item.avatarColor}}">
|
||||
<text>{{item.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="customer-info">
|
||||
<text class="customer-name">{{item.name}}</text>
|
||||
<text class="customer-detail">{{item.count}}次 · {{item.hours}}h · {{item.income}}</text>
|
||||
</view>
|
||||
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="toggle-btn" hover-class="toggle-btn--hover" bindtap="toggleRegularCustomer" wx:if="{{regularCustomers.length > 5}}">
|
||||
<text>{{regularCustomerExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
|
||||
<dev-fab />
|
||||
@@ -0,0 +1,833 @@
|
||||
/* pages/performance/performance.wxss — 业绩总览 */
|
||||
|
||||
/* ========== 全局设置 ========== */
|
||||
page {
|
||||
background-color: #f3f3f3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* ========== 加载态 / 空态 / 错误态 ========== */
|
||||
.page-loading,
|
||||
.page-empty,
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.empty-text,
|
||||
.error-text {
|
||||
font-size: 26rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background-color: #0052d9;
|
||||
color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.retry-btn--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ========== Banner 区域 ========== */
|
||||
/* 方案:SVG 做渐变底图(与 task-detail 保持一致)
|
||||
* 特点:通过 WXSS 可灵活调整位置、高度、缩放
|
||||
*/
|
||||
.banner-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 380rpx;
|
||||
}
|
||||
|
||||
/* 渐变底图 — SVG 包含渐变 + 光晕 */
|
||||
/* 方案:SVG 图片完整显示,通过调整位置和尺寸来控制显示区域
|
||||
* 超出 banner-section 的部分会被 overflow: hidden 裁剪
|
||||
* 调整方式:
|
||||
* - 修改 top/left 来移动图片位置(选择显示SVG的哪个部分)
|
||||
* - 修改 width/height 来缩放图片
|
||||
* - 负的 top 值:图片向上移,显示下半部分
|
||||
* - 正的 top 值:图片向下移,显示上半部分
|
||||
*/
|
||||
.banner-bg-img {
|
||||
position: absolute;
|
||||
top: -50rpx; /* 调整此值移动图片:负值向上移,正值向下移 */
|
||||
left: 0; /* 调整此值左右移动图片 */
|
||||
width: 100%; /* 调整此值缩放图片宽度 */
|
||||
height: auto; /* 保持图片比例,或设置具体值如 600rpx 来缩放 */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 40rpx;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 自定义导航栏 */
|
||||
.custom-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nav-back--hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* 个人信息 */
|
||||
.coach-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.coach-avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.coach-meta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.coach-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
}
|
||||
|
||||
.coach-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 56rpx;
|
||||
}
|
||||
|
||||
.coach-role-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.coach-store {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
/* 收入概览卡片 */
|
||||
.income-overview {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.income-card {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24rpx;
|
||||
padding: 30rpx;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.income-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 8rpx;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.income-value {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
.income-highlight {
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
/* ========== 通用 Section 卡片 ========== */
|
||||
.section-card {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 32rpx;
|
||||
margin: 24rpx 24rpx 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 24rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.title-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-primary { background: #0052d9; }
|
||||
.dot-success { background: #00a870; }
|
||||
.dot-cyan { background: #06b6d4; }
|
||||
.dot-pink { background: #ec4899; }
|
||||
|
||||
/* ========== 收入档位 ========== */
|
||||
.tier-card {
|
||||
position: relative;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.tier-current {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border: 2rpx solid #86efac;
|
||||
}
|
||||
|
||||
.tier-next {
|
||||
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
|
||||
border: 2rpx solid #fde047;
|
||||
}
|
||||
|
||||
.tier-badge {
|
||||
position: absolute;
|
||||
top: -16rpx;
|
||||
right: 24rpx;
|
||||
padding: 4rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.badge-current {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
}
|
||||
|
||||
.badge-next {
|
||||
background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%);
|
||||
}
|
||||
|
||||
.tier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tier-icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tier-emoji {
|
||||
font-size: 48rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tier-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.tier-label-green { color: #15803d; }
|
||||
.tier-label-yellow { color: #a16207; }
|
||||
|
||||
.tier-rates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.rate-item {
|
||||
text-align: center;
|
||||
width: 128rpx;
|
||||
}
|
||||
|
||||
.rate-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
line-height: 51rpx;
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.rate-desc {
|
||||
font-size: 20rpx;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.rate-green { color: #15803d; }
|
||||
.rate-green-light { color: #16a34a; }
|
||||
.rate-yellow { color: #a16207; }
|
||||
.rate-yellow-light { color: #ca8a04; }
|
||||
|
||||
.rate-divider {
|
||||
width: 2rpx;
|
||||
height: 64rpx;
|
||||
background: #bbf7d0;
|
||||
}
|
||||
|
||||
.rate-divider-yellow {
|
||||
background: #fef08a;
|
||||
}
|
||||
|
||||
/* 升级提示 */
|
||||
.upgrade-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(to right, #eff6ff, #eef2ff);
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
border: 2rpx solid #bfdbfe;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.upgrade-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.upgrade-emoji {
|
||||
font-size: 48rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upgrade-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upgrade-label {
|
||||
font-size: 22rpx;
|
||||
color: #5e5e5e;
|
||||
display: block;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.upgrade-hours {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1d4ed8;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.upgrade-hours-num {
|
||||
font-size: 30rpx;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.upgrade-bonus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: linear-gradient(to right, #fbbf24, #f97316);
|
||||
color: #ffffff;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.bonus-label {
|
||||
font-size: 20rpx;
|
||||
opacity: 0.9;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.bonus-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
line-height: 51rpx;
|
||||
}
|
||||
|
||||
/* ========== 本月业绩明细 ========== */
|
||||
.income-list {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.income-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 2rpx solid #f3f3f3;
|
||||
}
|
||||
|
||||
.income-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.income-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.income-icon-box {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f3f3f3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.income-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.income-item-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.income-item-desc {
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
margin-top: 4rpx;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.income-item-value {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.income-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16rpx;
|
||||
border-top: 2rpx solid #f3f3f3;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #8b8b8b;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #00a870;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
/* ========== 服务记录 ========== */
|
||||
.service-records-section {
|
||||
margin-top: 32rpx;
|
||||
margin: 32rpx -32rpx -32rpx;
|
||||
padding: 32rpx;
|
||||
background: rgba(243, 243, 243, 0.7);
|
||||
border-radius: 0 0 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.service-records-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.service-records-emoji {
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-records-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.dd-date {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
font-weight: 500;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.dd-line {
|
||||
flex: 1;
|
||||
height: 2rpx;
|
||||
background: #dcdcdc;
|
||||
}
|
||||
|
||||
.dd-stats {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-item:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.record-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 头像渐变色 */
|
||||
.avatar-from-blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
|
||||
.avatar-from-pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
|
||||
.avatar-from-teal { background: linear-gradient(135deg, #2dd4bf, #10b981); }
|
||||
.avatar-from-green { background: linear-gradient(135deg, #4ade80, #14b8a6); }
|
||||
/* 头像渐变色由 app.wxss 全局 .avatar-{key} 统一提供(VI §8) */
|
||||
.avatar-from-purple { background: linear-gradient(135deg, #c084fc, #8b5cf6); }
|
||||
.avatar-from-violet { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
|
||||
.avatar-from-amber { background: linear-gradient(135deg, #fbbf24, #eab308); }
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.record-name-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
flex-shrink: 0;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-hours {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.record-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.record-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.tag-basic {
|
||||
background: #ecfdf5;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.tag-vip {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.tag-tip {
|
||||
background: #fffbeb;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.record-location {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income {
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
flex-shrink: 0;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income-val {
|
||||
font-weight: 500;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
.records-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 16rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #8b8b8b;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.records-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
padding: 12rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #0052d9;
|
||||
font-weight: 500;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
/* ========== 新客 / 常客列表 ========== */
|
||||
.customer-list {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.customer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 2rpx solid #f3f3f3;
|
||||
}
|
||||
|
||||
.customer-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.customer-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.customer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.customer-detail {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
margin-top: 4rpx;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.customer-item--hover {
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
/* ========== 服务记录按钮 ========== */
|
||||
/* 并列按钮容器 */
|
||||
.records-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 120rpx;
|
||||
}
|
||||
|
||||
.records-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
font-size: 26rpx;
|
||||
color: #0052d9;
|
||||
line-height: 36rpx;
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.records-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
font-size: 26rpx;
|
||||
color: #0052d9;
|
||||
font-weight: 500;
|
||||
line-height: 36rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toggle-btn--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.view-all--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #0052d9;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.toggle-btn--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.view-all--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"navigationBarTitleText": "审核状态",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request } from "../../utils/request"
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 0,
|
||||
loading: true,
|
||||
status: "pending" as "pending" | "rejected",
|
||||
application: null as null | {
|
||||
id: number
|
||||
site_code: string
|
||||
role_type: string
|
||||
phone: string
|
||||
status: string
|
||||
reject_reason: string | null
|
||||
created_at: string
|
||||
},
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const { statusBarHeight } = wx.getWindowInfo()
|
||||
this.setData({ statusBarHeight })
|
||||
this.fetchStatus()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.fetchStatus()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.fetchStatus().finally(() => {
|
||||
wx.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
|
||||
async fetchStatus() {
|
||||
try {
|
||||
const data = await request({
|
||||
url: "/api/xcx/me",
|
||||
method: "GET",
|
||||
needAuth: true,
|
||||
})
|
||||
|
||||
// 同步 globalData 和 Storage
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.authUser = {
|
||||
userId: data.user_id,
|
||||
status: data.status,
|
||||
nickname: data.nickname,
|
||||
}
|
||||
wx.setStorageSync("userId", data.user_id)
|
||||
wx.setStorageSync("userStatus", data.status)
|
||||
|
||||
// 已通过 → 跳转主页
|
||||
if (data.status === "approved") {
|
||||
wx.reLaunch({ url: "/pages/mvp/mvp" })
|
||||
return
|
||||
}
|
||||
|
||||
// 已禁用 → 跳转无权限页
|
||||
if (data.status === "disabled") {
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
return
|
||||
}
|
||||
|
||||
// 已拒绝 → 跳转无权限页
|
||||
if (data.status === "rejected") {
|
||||
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
|
||||
return
|
||||
}
|
||||
|
||||
// 全新用户(尚未提交申请)→ 跳回申请页
|
||||
if (data.status === "new") {
|
||||
wx.reLaunch({ url: "/pages/apply/apply" })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
loading: false,
|
||||
status: data.status,
|
||||
application: data.latest_application || null,
|
||||
})
|
||||
} catch (err: any) {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: "获取状态失败", icon: "none" })
|
||||
}
|
||||
},
|
||||
|
||||
onSwitchAccount() {
|
||||
const app = getApp<IAppOption>()
|
||||
app.globalData.token = undefined
|
||||
app.globalData.refreshToken = undefined
|
||||
app.globalData.authUser = undefined
|
||||
wx.removeStorageSync("token")
|
||||
wx.removeStorageSync("refreshToken")
|
||||
wx.removeStorageSync("userId")
|
||||
wx.removeStorageSync("userStatus")
|
||||
wx.reLaunch({ url: "/pages/login/login" })
|
||||
},
|
||||
})
|
||||
116
apps/DEMO-miniprogram/miniprogram/pages/reviewing/reviewing.wxml
Normal file
116
apps/DEMO-miniprogram/miniprogram/pages/reviewing/reviewing.wxml
Normal 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 />
|
||||
427
apps/DEMO-miniprogram/miniprogram/pages/reviewing/reviewing.wxss
Normal file
427
apps/DEMO-miniprogram/miniprogram/pages/reviewing/reviewing.wxss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"navigationBarTitleText": "任务详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"note-modal": "/components/note-modal/note-modal",
|
||||
"abandon-modal": "/components/abandon-modal/abandon-modal",
|
||||
"star-rating": "/components/star-rating/star-rating",
|
||||
"heart-icon": "/components/heart-icon/heart-icon",
|
||||
"ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon",
|
||||
"ai-title-badge": "/components/ai-title-badge/ai-title-badge",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"clue-card": "/components/clue-card/clue-card",
|
||||
"service-record-card": "/components/service-record-card/service-record-card",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { mockTaskDetails } from '../../utils/mock-data'
|
||||
import type { TaskDetail, Note } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
import { formatMoney } from '../../utils/money'
|
||||
|
||||
/** 维客线索项 */
|
||||
interface RetentionClue {
|
||||
tag: string
|
||||
tagColor: 'primary' | 'success' | 'purple' | 'error'
|
||||
emoji: string
|
||||
text: string
|
||||
source: string
|
||||
desc?: string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/** 服务记录项 */
|
||||
interface ServiceRecord {
|
||||
table: string
|
||||
type: string
|
||||
typeClass: 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
|
||||
/** 卡片类型:course=普通课,recharge=充值提成 */
|
||||
recordType?: 'course' | 'recharge'
|
||||
duration: number // 折算后课时(小时,number)
|
||||
durationRaw?: number // 折算前课时(小时,number,可选)
|
||||
income: number // 收入(元,整数)
|
||||
/** 是否预估金额 */
|
||||
isEstimate?: boolean
|
||||
drinks: string
|
||||
date: string
|
||||
}
|
||||
|
||||
/** 服务汇总 */
|
||||
interface ServiceSummary {
|
||||
totalHours: number
|
||||
totalIncome: number
|
||||
avgIncome: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ISO/空格分隔的日期字符串格式化为中文短格式
|
||||
* "2026-02-07T21:30" → "2月7日 21:30"
|
||||
* 与 customer-service-records 页面拼接方式保持一致
|
||||
*/
|
||||
function formatServiceDate(dateStr: string): string {
|
||||
if (!dateStr) return dateStr
|
||||
// 兼容 "2026-02-07T21:30" 和 "2026-02-07 21:30"
|
||||
const normalized = dateStr.replace('T', ' ')
|
||||
const [datePart, timePart] = normalized.split(' ')
|
||||
if (!datePart) return dateStr
|
||||
const parts = datePart.split('-')
|
||||
if (parts.length < 3) return dateStr
|
||||
const month = parseInt(parts[1], 10)
|
||||
const day = parseInt(parts[2], 10)
|
||||
return timePart ? `${month}月${day}日 ${timePart}` : `${month}月${day}日`
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
detail: null as TaskDetail | null,
|
||||
sortedNotes: [] as Note[],
|
||||
noteModalVisible: false,
|
||||
|
||||
// --- 维客线索 ---
|
||||
retentionClues: [
|
||||
{ tag: '客户\n基础', tagColor: 'primary', emoji: '🎂', text: '生日 3月15日 · VIP会员 · 注册2年', source: 'By:系统', expanded: false },
|
||||
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🌙', text: '常来夜场 · 月均4-5次', source: 'By:系统', expanded: false },
|
||||
{ tag: '消费\n习惯', tagColor: 'success', emoji: '💰', text: '高客单价', source: 'By:系统', desc: '近60天场均消费 ¥420,高于门店均值 ¥180;偏好夜场时段,酒水附加消费占比 35%', expanded: false },
|
||||
{ tag: '玩法\n偏好', tagColor: 'purple', emoji: '🎱', text: '偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣', source: 'By:系统', desc: '中式八球占比 60%,斯诺克 30%;近2周开始尝试花式九球,技术水平中等偏上', expanded: false },
|
||||
{ tag: '重要\n反馈', tagColor: 'error', emoji: '⚠️', text: '上次提到想练斯诺克走位', source: 'By:小燕', desc: '2月7日到店时主动提及,希望有针对性的走位训练;建议下次安排斯诺克专项课程', expanded: false },
|
||||
{ tag: '社交\n偏好', tagColor: 'purple', emoji: '👥', text: '喜欢带朋友来玩 · 社交型客户', source: 'By:系统', desc: '70%的到店记录都是多人消费,经常介绍新客户;建议推荐团建套餐和会员推荐奖励', expanded: false },
|
||||
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🍷', text: '酒水消费占比高 · 偏好高端酒水', source: 'By:系统', desc: '每次到店必点酒水,偏好芝华士、百威等品牌;酒水消费占总消费的40%', expanded: false },
|
||||
{ tag: '重要\n反馈', tagColor: 'error', emoji: '💬', text: '上次提到想办生日派对', source: 'By:Lucy', desc: '3月15日生日,想在店里办派对,预计10-15人;已记录需求,建议提前联系确认', expanded: false },
|
||||
] as RetentionClue[],
|
||||
|
||||
// --- 话术参考 ---
|
||||
talkingPoints: [
|
||||
'王哥您好,好久不见!最近店里新到了几张国际标准的斯诺克球桌,知道您是斯诺克爱好者,想邀请您有空来体验一下~',
|
||||
'王哥,最近忙吗?这周末我们有个老客户专属的球友交流赛,奖品还挺丰富的,您要不要来参加?',
|
||||
'王哥好呀,上次您提到想练练斯诺克的走位,我最近研究了一些新的训练方法,下次来的时候可以一起试试~',
|
||||
'王哥,好久没见您了,您的老位置 A12 号台一直给您留着呢!最近晚上人不多,环境特别好,随时欢迎您来~',
|
||||
'王哥您好,我们这个月推出了储值会员专属的夜场优惠套餐,包含球台+酒水,性价比很高,给您留意着呢~',
|
||||
] as string[],
|
||||
copiedIndex: -1,
|
||||
|
||||
// --- 近期服务记录 ---
|
||||
serviceSummary: { totalHours: 6.0, totalIncome: 510, avgIncome: 170 } as ServiceSummary,
|
||||
serviceRecords: [
|
||||
{ table: 'A12号台', type: '基础课', typeClass: 'basic', duration: 2.5, durationRaw: 3.0, income: 200, isEstimate: true, drinks: '🍷 百威x2 红牛x1', date: formatServiceDate('2026-02-07T21:30') },
|
||||
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: 2.0, durationRaw: 2.0, income: 160, isEstimate: false, drinks: '🍷 可乐x1', date: formatServiceDate('2026-02-01T20:30') },
|
||||
{ table: 'VIP1号房', type: '包厢课', typeClass: 'vip', duration: 1.5, durationRaw: 1.5, income: 150, isEstimate: true, drinks: '🍷 芝华士x1 矿泉水x2', date: formatServiceDate('2026-01-28T19:00') },
|
||||
{ table: '', type: '充值', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 80, isEstimate: false, drinks: '', date: formatServiceDate('2026-01-15T10:00') },
|
||||
] as ServiceRecord[],
|
||||
|
||||
// --- 放弃弹窗 ---
|
||||
abandonModalVisible: false,
|
||||
|
||||
// --- 手机号显示 ---
|
||||
phoneVisible: false,
|
||||
|
||||
// --- 储值等级 ---
|
||||
storageLevel: '非常多',
|
||||
|
||||
// --- 关系等级相关 ---
|
||||
relationLevel: 'excellent' as 'poor' | 'normal' | 'good' | 'excellent',
|
||||
relationLevelText: '很好',
|
||||
relationColor: '#e91e63',
|
||||
|
||||
// --- Banner 背景(根据任务类型动态切换)---
|
||||
bannerBgSvg: '/assets/images/banner-bg-red-aurora.svg',
|
||||
|
||||
// --- 调试面板 ---
|
||||
showDebugPanel: false,
|
||||
debugTaskType: 'high_priority',
|
||||
debugHeartScore: 8.5,
|
||||
debugShowExpandBtn: true, // 调试:备注弹窗是否显示展开/收起按钮
|
||||
|
||||
// --- AI 配色 ---
|
||||
aiColor: 'indigo' as 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple',
|
||||
},
|
||||
|
||||
onLoad(options: { id?: string }) {
|
||||
const id = options?.id || ''
|
||||
// 随机 AI 配色
|
||||
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
|
||||
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
|
||||
this.setData({ aiColor })
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0]
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
|
||||
// 根据任务类型设置 Banner 背景
|
||||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
if (detail.taskType === 'high_priority') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
} else if (detail.taskType === 'priority_recall') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
|
||||
} else if (detail.taskType === 'relationship') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||||
} else if (detail.taskType === 'callback') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
|
||||
}
|
||||
|
||||
// 添加更多 mock 备注
|
||||
const mockNotes: Note[] = [
|
||||
{ id: 'note-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-10T16:30', score: 10 },
|
||||
{ id: 'note-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-05T14:20', score: 7.5 },
|
||||
{ id: 'note-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-28T18:45', score: 6 },
|
||||
{ id: 'note-4', content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-20T21:15', score: 9.5 },
|
||||
{ id: 'note-5', content: '完成高优先召回任务。客户反馈最近工作太忙,这周末会来店里。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-15T10:30', score: 8 },
|
||||
]
|
||||
|
||||
// 附加 timeLabel 字段
|
||||
const notesWithLabel = mockNotes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
const sorted = sortByTimestamp(notesWithLabel) as (Note & { timeLabel: string })[]
|
||||
this.updateRelationshipDisplay(detail.heartScore)
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
sortedNotes: sorted,
|
||||
debugHeartScore: detail.heartScore,
|
||||
bannerBgSvg,
|
||||
})
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 更新关系等级显示 */
|
||||
updateRelationshipDisplay(score: number) {
|
||||
let level: 'poor' | 'normal' | 'good' | 'excellent' = 'poor'
|
||||
let text = '待发展'
|
||||
let color = '#64748b' // 蓝灰色,区别于蓝色爱心
|
||||
|
||||
if (score > 8.5) {
|
||||
level = 'excellent'
|
||||
text = '很好'
|
||||
color = '#e91e63' // 深粉色,区别于粉红爱心
|
||||
} else if (score >= 6) {
|
||||
level = 'good'
|
||||
text = '良好'
|
||||
color = '#ea580c' // 深橙色,区别于橙色爱心
|
||||
} else if (score >= 3.5) {
|
||||
level = 'normal'
|
||||
text = '一般'
|
||||
color = '#eab308' // 金黄色,区别于黄色爱心
|
||||
}
|
||||
|
||||
this.setData({
|
||||
relationLevel: level,
|
||||
relationLevelText: text,
|
||||
relationColor: color,
|
||||
})
|
||||
},
|
||||
|
||||
/** 返回 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 重试 */
|
||||
onRetry() {
|
||||
const id = this.data.detail?.id || ''
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
/** 查看/隐藏手机号 */
|
||||
onTogglePhone() {
|
||||
this.setData({ phoneVisible: !this.data.phoneVisible })
|
||||
},
|
||||
|
||||
/** 复制手机号 */
|
||||
onCopyPhone() {
|
||||
const phone = '13812345678'
|
||||
wx.setClipboardData({
|
||||
data: phone,
|
||||
success: () => {
|
||||
wx.showToast({ title: '手机号码已复制', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 展开/收起维客线索描述 */
|
||||
onToggleClue(e: WechatMiniprogram.BaseEvent) {
|
||||
const idx = e.currentTarget.dataset.index as number
|
||||
const key = `retentionClues[${idx}].expanded`
|
||||
const current = this.data.retentionClues[idx]?.expanded || false
|
||||
this.setData({ [key]: !current })
|
||||
},
|
||||
|
||||
/** 复制话术 */
|
||||
onCopySpeech(e: WechatMiniprogram.BaseEvent) {
|
||||
const idx = e.currentTarget.dataset.index as number
|
||||
const text = this.data.talkingPoints[idx]
|
||||
if (!text) return
|
||||
wx.setClipboardData({
|
||||
data: text,
|
||||
success: () => {
|
||||
this.setData({ copiedIndex: idx })
|
||||
setTimeout(() => this.setData({ copiedIndex: -1 }), 2000)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 打开备注弹窗 */
|
||||
onAddNote() {
|
||||
this.setData({ noteModalVisible: true })
|
||||
},
|
||||
|
||||
/** 备注弹窗确认 */
|
||||
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
|
||||
const { content } = e.detail
|
||||
wx.showToast({ title: '备注已保存', icon: 'success' })
|
||||
this.setData({ noteModalVisible: false })
|
||||
const newNote: Note = {
|
||||
id: `note-${Date.now()}`,
|
||||
content,
|
||||
tagType: 'customer',
|
||||
tagLabel: `客户:${this.data.detail?.customerName || ''}`,
|
||||
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
}
|
||||
this.setData({ sortedNotes: [newNote, ...this.data.sortedNotes] })
|
||||
},
|
||||
|
||||
/** 备注弹窗取消 */
|
||||
onNoteCancel() {
|
||||
this.setData({ noteModalVisible: false })
|
||||
},
|
||||
|
||||
/** 删除备注 */
|
||||
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
|
||||
const noteId = e.currentTarget.dataset.id as string
|
||||
wx.showModal({
|
||||
title: '删除备注',
|
||||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||||
confirmColor: '#e34d59',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
|
||||
this.setData({ sortedNotes: notes })
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 放弃/取消放弃 */
|
||||
onAbandon() {
|
||||
// 如果已放弃,直接取消放弃(不需要二次确认)
|
||||
if (this.data.detail?.status === 'abandoned') {
|
||||
this.cancelAbandon()
|
||||
return
|
||||
}
|
||||
// 否则打开放弃弹窗
|
||||
this.setData({ abandonModalVisible: true })
|
||||
},
|
||||
|
||||
/** 取消放弃 - 直接修改状态 */
|
||||
cancelAbandon() {
|
||||
wx.showLoading({ title: '处理中...' })
|
||||
setTimeout(() => {
|
||||
wx.hideLoading()
|
||||
// 更新状态为 pending
|
||||
this.setData({ 'detail.status': 'pending' })
|
||||
wx.showToast({ title: '已取消放弃', icon: 'success' })
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 放弃 — 确认 */
|
||||
onAbandonConfirm(_e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
|
||||
this.setData({ abandonModalVisible: false })
|
||||
wx.showLoading({ title: '处理中...' })
|
||||
setTimeout(() => {
|
||||
wx.hideLoading()
|
||||
// 更新状态为 abandoned
|
||||
this.setData({ 'detail.status': 'abandoned' })
|
||||
wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 放弃 — 取消 */
|
||||
onAbandonCancel() {
|
||||
this.setData({ abandonModalVisible: false })
|
||||
},
|
||||
|
||||
/** 问问助手 */
|
||||
onAskAssistant() {
|
||||
const customerId = this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/chat/chat?customerId=${customerId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 查看全部服务记录 */
|
||||
onViewAllRecords() {
|
||||
const customerId = this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** noop — 阻止弹窗背景点击冒泡 */
|
||||
noop() {},
|
||||
|
||||
/** 切换调试面板 */
|
||||
toggleDebugPanel() {
|
||||
this.setData({ showDebugPanel: !this.data.showDebugPanel })
|
||||
},
|
||||
|
||||
/** 调试 - 切换任务类型 */
|
||||
onDebugTaskType(e: WechatMiniprogram.BaseEvent) {
|
||||
const type = e.currentTarget.dataset.type as string
|
||||
const typeMap: Record<string, string> = {
|
||||
high_priority: '高优先召回',
|
||||
priority_recall: '优先召回',
|
||||
relationship: '关系构建',
|
||||
callback: '客户回访',
|
||||
}
|
||||
|
||||
// 根据任务类型设置 Banner 背景
|
||||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
if (type === 'high_priority') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
} else if (type === 'priority_recall') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
|
||||
} else if (type === 'relationship') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||||
} else if (type === 'callback') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
|
||||
}
|
||||
|
||||
this.setData({
|
||||
debugTaskType: type,
|
||||
'detail.taskTypeLabel': typeMap[type] || '高优先召回',
|
||||
bannerBgSvg,
|
||||
})
|
||||
},
|
||||
|
||||
/** 调试 - 调整关系数值 */
|
||||
onDebugHeartScore(e: WechatMiniprogram.SliderChange) {
|
||||
const score = e.detail.value
|
||||
this.setData({
|
||||
debugHeartScore: score,
|
||||
'detail.heartScore': score,
|
||||
})
|
||||
this.updateRelationshipDisplay(score)
|
||||
},
|
||||
|
||||
/** 调试 - 切换备注弹窗展开按钮 */
|
||||
onDebugToggleExpandBtn(e: WechatMiniprogram.BaseEvent) {
|
||||
const value = e.currentTarget.dataset.value as boolean
|
||||
this.setData({ debugShowExpandBtn: value })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
<wxs src="../../utils/format.wxs" module="fmt" />
|
||||
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
|
||||
<text class="empty-text">未找到任务信息</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败</text>
|
||||
<view class="retry-btn" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:elif="{{pageState === 'normal'}}">
|
||||
|
||||
<!-- Banner 区域(SVG 背景)-->
|
||||
<view class="banner-area">
|
||||
<image src="{{bannerBgSvg}}" class="banner-bg-svg" mode="scaleToFill" />
|
||||
<view class="banner-content">
|
||||
<!-- 放弃/取消放弃按钮 - 右上角 -->
|
||||
<view class="banner-abandon-btn" bindtap="onAbandon" hover-class="banner-abandon-btn--hover">
|
||||
<text class="banner-abandon-text">{{detail.status === 'abandoned' ? '取消放弃' : '放弃'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="customer-info">
|
||||
<view class="avatar-box">
|
||||
<text class="avatar-text">{{detail.customerName[0] || '?'}}</text>
|
||||
</view>
|
||||
<view class="info-right">
|
||||
<view class="name-row">
|
||||
<text class="customer-name">{{detail.customerName}}</text>
|
||||
<text class="task-type-tag">{{detail.taskTypeLabel || '高优先召回'}}</text>
|
||||
</view>
|
||||
<view class="sub-info">
|
||||
<text class="phone">{{phoneVisible ? '13812345678' : '138****5678'}}</text>
|
||||
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
|
||||
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
|
||||
</view>
|
||||
<text class="storage-level-text">💰 储值 {{storageLevel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-content">
|
||||
|
||||
<!-- 与我的关系(动态颜色)-->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-pink">与我的关系</text>
|
||||
<ai-title-badge color="{{aiColor}}" />
|
||||
</view>
|
||||
<view class="relationship-row">
|
||||
<view class="rel-tag rel-level-{{relationLevel}}">
|
||||
<heart-icon score="{{detail.heartScore}}" />
|
||||
<text class="rel-tag-text">{{relationLevelText}}</text>
|
||||
</view>
|
||||
<view class="rel-bar">
|
||||
<view class="rel-bar-fill rel-fill-{{relationLevel}}" style="width: {{detail.heartScore * 10}}%; background: {{relationColor}};"></view>
|
||||
</view>
|
||||
<text class="rel-score" style="color: {{relationColor}};">{{fmt.toFixed(detail.heartScore, 1)}}</text>
|
||||
</view>
|
||||
<view class="card-desc-wrap">
|
||||
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务建议 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">任务建议</text>
|
||||
<ai-title-badge color="{{aiColor}}" />
|
||||
</view>
|
||||
<view class="suggestion-box">
|
||||
<view class="suggestion-header">
|
||||
<text class="suggestion-icon-text">💡 建议执行</text>
|
||||
</view>
|
||||
<view class="suggestion-body">
|
||||
<view class="suggestion-intro-wrap">
|
||||
<text class="suggestion-intro">该客户已有 15 天未到店,存在流失风险。建议通过微信联系:</text>
|
||||
</view>
|
||||
<view class="suggestion-item" wx:for="{{detail.aiAnalysis.suggestions}}" wx:key="index">
|
||||
<text class="suggestion-item-text">• {{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="speech-section">
|
||||
<text class="speech-title">💬 话术参考</text>
|
||||
<view class="speech-list">
|
||||
<view class="speech-bubble" wx:for="{{talkingPoints}}" wx:key="index">
|
||||
<view class="speech-text-wrap">
|
||||
<ai-inline-icon color="{{aiColor}}" />
|
||||
<text class="speech-text">{{item}}</text>
|
||||
</view>
|
||||
<view class="speech-copy-row">
|
||||
<view class="copy-btn" bindtap="onCopySpeech" data-index="{{index}}" hover-class="copy-btn--hover">
|
||||
<text class="copy-btn-text">{{copiedIndex === index ? '✓ 已复制' : '复制'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="speech-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维客线索 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-green">维客线索</text>
|
||||
<ai-title-badge color="{{aiColor}}" />
|
||||
</view>
|
||||
<view class="clue-list">
|
||||
<clue-card
|
||||
wx:for="{{retentionClues}}"
|
||||
wx:key="index"
|
||||
tag="{{item.tag}}"
|
||||
category="{{item.tagColor}}"
|
||||
emoji="{{item.emoji}}"
|
||||
title="{{item.text}}"
|
||||
source="{{item.source}}"
|
||||
content="{{item.desc || ''}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我给TA的备注 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-blue">我给TA的备注</text>
|
||||
<text class="note-count">{{sortedNotes.length}} 条备注</text>
|
||||
</view>
|
||||
<block wx:if="{{sortedNotes.length > 0}}">
|
||||
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
|
||||
<view class="note-top">
|
||||
<view class="note-date-wrap">
|
||||
<text class="note-date">{{item.timeLabel || item.createdAt}}</text>
|
||||
</view>
|
||||
<view class="note-top-right">
|
||||
<star-rating score="{{item.score || 3}}" size="28rpx" readonly="{{true}}" />
|
||||
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
|
||||
<t-icon name="delete" size="32rpx" color="#a6a6a6" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="note-content-wrap">
|
||||
<text class="note-content">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="note-empty" wx:else>
|
||||
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
|
||||
<text class="empty-hint">暂无备注</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 60天内服务记录 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-blue">60天内服务记录</text>
|
||||
<text class="note-count">共 {{serviceRecords.length}} 次</text>
|
||||
</view>
|
||||
<!-- 汇总统计 -->
|
||||
<view class="svc-summary">
|
||||
<view class="svc-summary-item svc-summary-blue">
|
||||
<view class="svc-summary-value-row">
|
||||
<text class="svc-summary-value svc-val-blue">{{fmt.hours(serviceSummary.totalHours)}}</text>
|
||||
</view>
|
||||
<text class="svc-summary-label">总时长</text>
|
||||
</view>
|
||||
<view class="svc-summary-item svc-summary-green">
|
||||
<text class="svc-summary-value svc-val-green">{{fmt.money(serviceSummary.totalIncome)}}</text>
|
||||
<text class="svc-summary-label">总收入</text>
|
||||
</view>
|
||||
<view class="svc-summary-item svc-summary-orange">
|
||||
<text class="svc-summary-value svc-val-orange">{{fmt.money(serviceSummary.avgIncome)}}</text>
|
||||
<text class="svc-summary-label">场均</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 记录列表(使用 service-record-card 组件)-->
|
||||
<view class="svc-list">
|
||||
<service-record-card
|
||||
wx:for="{{serviceRecords}}"
|
||||
wx:key="index"
|
||||
time="{{item.date}}"
|
||||
course-label="{{item.type}}"
|
||||
type-class="{{item.typeClass}}"
|
||||
type="{{item.recordType || 'course'}}"
|
||||
table-no="{{item.table}}"
|
||||
hours="{{item.duration}}"
|
||||
hours-raw="{{item.durationRaw}}"
|
||||
drinks="{{item.drinks}}"
|
||||
income="{{item.income}}"
|
||||
is-estimate="{{item.isEstimate}}"
|
||||
/>
|
||||
</view>
|
||||
<view class="svc-view-all" bindtap="onViewAllRecords" hover-class="svc-view-all--hover">
|
||||
<text class="svc-view-all-text">查看全部服务记录 →</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar safe-area-bottom">
|
||||
<view class="btn-ask" bindtap="onAskAssistant" hover-class="btn-ask--hover">
|
||||
<t-icon name="chat" size="36rpx" color="#ffffff" />
|
||||
<text class="btn-text">问问助手</text>
|
||||
</view>
|
||||
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
|
||||
<t-icon name="edit-1" size="36rpx" color="#242424" />
|
||||
<text class="btn-text-dark">备注</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.customerName || ''}}" initialScore="{{0}}" initialContent="" showExpandBtn="{{detail.taskType !== 'callback'}}" showRating="{{true}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
|
||||
|
||||
<!-- 放弃弹窗 -->
|
||||
<abandon-modal
|
||||
visible="{{abandonModalVisible}}"
|
||||
customerName="{{detail.customerName || ''}}"
|
||||
bind:confirm="onAbandonConfirm"
|
||||
bind:cancel="onAbandonCancel"
|
||||
/>
|
||||
|
||||
<!-- 页面调试工具 -->
|
||||
<view class="debug-panel" wx:if="{{showDebugPanel}}">
|
||||
<view class="debug-header">
|
||||
<text class="debug-title">🔧 页面调试</text>
|
||||
<view class="debug-close" bindtap="toggleDebugPanel" hover-class="debug-close--hover">
|
||||
<t-icon name="close" size="32rpx" color="#5e5e5e" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="debug-section">
|
||||
<text class="debug-label">任务类型:</text>
|
||||
<view class="debug-btn-group">
|
||||
<view class="debug-btn {{debugTaskType === 'high_priority' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="high_priority">高优先召回</view>
|
||||
<view class="debug-btn {{debugTaskType === 'priority_recall' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="priority_recall">优先召回</view>
|
||||
<view class="debug-btn {{debugTaskType === 'relationship' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="relationship">关系构建</view>
|
||||
<view class="debug-btn {{debugTaskType === 'callback' ? 'debug-btn--active' : ''}}" bindtap="onDebugTaskType" data-type="callback">客户回访</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="debug-section">
|
||||
<text class="debug-label">关系数值:{{debugHeartScore}}</text>
|
||||
<slider class="debug-slider" min="0" max="10" step="0.5" value="{{debugHeartScore}}" bindchange="onDebugHeartScore" activeColor="#ec4899" backgroundColor="#f3f3f3" block-size="24" />
|
||||
<view class="debug-hint">
|
||||
<text>0-3.5: 待发展</text>
|
||||
<text>3.5-6: 一般</text>
|
||||
<text>6-8.5: 良好</text>
|
||||
<text>>8.5: 很好</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 调试面板触发按钮 -->
|
||||
<view class="debug-trigger" bindtap="toggleDebugPanel" hover-class="debug-trigger--hover">
|
||||
<text>🔧</text>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
1062
apps/DEMO-miniprogram/miniprogram/pages/task-detail/task-detail.wxss
Normal file
1062
apps/DEMO-miniprogram/miniprogram/pages/task-detail/task-detail.wxss
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationBarTitleText": "任务",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
|
||||
"heart-icon": "/components/heart-icon/heart-icon",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon",
|
||||
"note-modal": "/components/note-modal/note-modal",
|
||||
"abandon-modal": "/components/abandon-modal/abandon-modal",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
701
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.ts
Normal file
701
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
/* 任务列表页 — 2026-03-13 全量重写,1:1 对齐 H5 原型 task-list.html */
|
||||
/* AI_CHANGELOG
|
||||
| 日期 | Prompt | 变更 |
|
||||
|------|--------|------|
|
||||
| 2026-03-13 | 重写 task-list 1:1 还原 H5 | 对齐 H5 四种任务类型、border-left 彩条颜色、圆形红戳、mock 数据贴近原型 |
|
||||
| 2026-03-13 | banner 错位重做 | 添加 userName/userRole/storeName 到 page data;移除 banner 组件引用 |
|
||||
| 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 |
|
||||
| 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap(标签恢复 CSS 渐变实现) |
|
||||
*/
|
||||
import { mockTasks, mockPerformance } from '../../utils/mock-data'
|
||||
import type { Task } from '../../utils/mock-data'
|
||||
import { formatMoney } from '../../utils/money'
|
||||
import { formatDeadline } from '../../utils/time'
|
||||
|
||||
/** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail,由详情页根据 taskId 动态展示内容 */
|
||||
const DETAIL_ROUTE = '/pages/task-detail/task-detail'
|
||||
|
||||
/* ╔══════════════════════════════════════════════════════╗
|
||||
* ║ 进度条动画参数 — 在此调节 ║
|
||||
* ╚══════════════════════════════════════════════════════╝
|
||||
*
|
||||
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
|
||||
*
|
||||
* ┌─────────────┐ SPARK_DELAY_MS ┌─────────────┐ NEXT_LOOP_DELAY_MS ┌─────────────┐
|
||||
* │ 高光匀速扫过 │ ───────────────▶ │ 火花迸发 │ ──────────────────▶ │ 下一轮 │
|
||||
* │ 时长由速度决定│ │ SPARK_DUR_MS│ │(重新读进度) │
|
||||
* └─────────────┘ └─────────────┘ └─────────────┘
|
||||
*
|
||||
* SHINE_SPEED : 高光移动速度,范围 1~100
|
||||
* 1 = 最慢,最宽进度条(100%)下 5 秒走完
|
||||
* 100 = 最快,最宽进度条(100%)下 0.05 秒走完
|
||||
* 实际时长 = 基准时长 × (filledPct/100)
|
||||
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) × (5-0.05)s
|
||||
*
|
||||
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
|
||||
* 正数 = 高光结束后停顿再点亮
|
||||
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
|
||||
*
|
||||
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
|
||||
*
|
||||
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
|
||||
* 正数 = 停顿一段时间
|
||||
* 负数 = 火花还未消散完,高光已从左端启动
|
||||
*/
|
||||
const SHINE_SPEED = 70 // 1~100,速度值
|
||||
const SPARK_DELAY_MS = -200 // 毫秒,高光结束→光柱点亮+火花(负=提前)
|
||||
const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
|
||||
const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束→下轮高光(负=提前)
|
||||
|
||||
/* 根据速度值和进度百分比计算高光时长
|
||||
* 高光宽度固定(SHINE_WIDTH_RPX),需要走过的距离 = 填充条宽度 + 高光宽度
|
||||
* 轨道宽度约 634rpx(750 - 左右padding各58rpx),高光宽度约占轨道 19%
|
||||
* 时长正比于需要走过的总距离,保证视觉速度恒定
|
||||
*
|
||||
* 速度1 → baseDur=5000ms(最慢),速度100 → baseDur=50ms(最快)
|
||||
* shineDurMs = baseDur × (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
*/
|
||||
const SHINE_WIDTH_RPX = 120 // rpx,需与 WXSS 的 --shine-width 保持一致
|
||||
const TRACK_WIDTH_RPX = 634 // rpx,进度条轨道宽度(750 - padding 116rpx)
|
||||
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ≈19%
|
||||
|
||||
function calcShineDur(filledPct: number): number {
|
||||
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
|
||||
const baseDur = 5000 - t * (5000 - 50) // ms,走完100%进度条所需时长
|
||||
// 实际距离 = 填充条 + 高光自身,相对于(100% + 高光宽度%)归一化
|
||||
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
return Math.max(50, Math.round(baseDur * distRatio))
|
||||
}
|
||||
|
||||
/** 扩展任务字段 */
|
||||
interface EnrichedTask extends Task {
|
||||
lastVisitDays: number
|
||||
balanceLabel: string
|
||||
aiSuggestion: string
|
||||
abandonReason?: string
|
||||
isAbandoned: boolean
|
||||
deadlineLabel: string
|
||||
deadlineStyle: 'normal' | 'warning' | 'danger' | 'muted'
|
||||
}
|
||||
|
||||
/** 刻度项 */
|
||||
interface TickItem {
|
||||
value: number // 刻度数值(如 100)
|
||||
label: string // 显示文字(如 '100')
|
||||
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
|
||||
highlight: boolean // 是否加粗高亮
|
||||
}
|
||||
|
||||
/** Mock: 根据档位节点数组生成刻度数据 */
|
||||
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
|
||||
return tierNodes.map((v, i) => ({
|
||||
value: v,
|
||||
label: String(v),
|
||||
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
|
||||
highlight: i === 2, // 第3个档位(如130h)高亮,可由接口控制
|
||||
}))
|
||||
}
|
||||
|
||||
/** P0: 业绩进度卡片数据 */
|
||||
interface PerfData {
|
||||
nextTierHours: number
|
||||
remainHours: number
|
||||
currentTier: number
|
||||
tierProgress: number
|
||||
filledPct: number
|
||||
clampedSparkPct: number
|
||||
ticks: TickItem[] // 刻度数组,由接口传入(Mock 时由 buildTicks 生成)
|
||||
shineDurMs: number
|
||||
sparkDurMs: number
|
||||
shineRunning: boolean
|
||||
sparkRunning: boolean
|
||||
basicHours: string
|
||||
bonusHours: string
|
||||
totalHours: string
|
||||
tierCompleted: boolean
|
||||
bonusMoney: string
|
||||
incomeMonth: string
|
||||
prevMonth: string
|
||||
incomeFormatted: string
|
||||
incomeTrend: string
|
||||
incomeTrendDir: 'up' | 'down'
|
||||
}
|
||||
|
||||
/** Mock: 为任务附加扩展字段 */
|
||||
function enrichTask(task: Task): EnrichedTask {
|
||||
const daysSeed = (task.id.charCodeAt(task.id.length - 1) % 15) + 1
|
||||
const balanceSeedNum = ((task.id.charCodeAt(task.id.length - 1) * 137) % 5000) + 200
|
||||
const suggestions = [
|
||||
'建议推荐斯诺克进阶课程,提升客户粘性',
|
||||
'客户近期消费下降,建议电话关怀了解原因',
|
||||
'适合推荐周末球友赛活动,增强社交体验',
|
||||
'高价值客户,建议维护关系并推荐VIP权益',
|
||||
'新客户首次体验后未续费,建议跟进意向',
|
||||
]
|
||||
const suggIdx = task.id.charCodeAt(task.id.length - 1) % suggestions.length
|
||||
|
||||
return {
|
||||
...task,
|
||||
lastVisitDays: daysSeed,
|
||||
balanceLabel: formatMoney(balanceSeedNum),
|
||||
aiSuggestion: suggestions[suggIdx],
|
||||
isAbandoned: task.status === 'abandoned',
|
||||
abandonReason: task.status === 'abandoned' ? '客户已转至其他门店' : undefined,
|
||||
deadlineLabel: formatDeadline((task as any).deadline).text,
|
||||
deadlineStyle: formatDeadline((task as any).deadline).style,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock: 构造业绩进度卡片数据 — 对齐 H5 原型数值 */
|
||||
function buildPerfData(): PerfData {
|
||||
const total = 87.5
|
||||
const filledPct = Math.min(100, parseFloat(((total / 220) * 100).toFixed(1)))
|
||||
// Mock 档位节点:实际由接口返回,格式为 number[]
|
||||
const tierNodes = [0, 100, 130, 160, 190, 220]
|
||||
return {
|
||||
nextTierHours: 100,
|
||||
remainHours: 12.5,
|
||||
currentTier: 1,
|
||||
tierProgress: 58,
|
||||
filledPct,
|
||||
clampedSparkPct: Math.max(0, Math.min(100, filledPct)),
|
||||
ticks: buildTicks(tierNodes, 220),
|
||||
shineDurMs: calcShineDur(filledPct),
|
||||
sparkDurMs: SPARK_DUR_MS,
|
||||
shineRunning: false,
|
||||
sparkRunning: false,
|
||||
basicHours: '77.5',
|
||||
bonusHours: '12',
|
||||
totalHours: String(total),
|
||||
tierCompleted: true,
|
||||
bonusMoney: '800',
|
||||
incomeMonth: '2月',
|
||||
prevMonth: '1月',
|
||||
incomeFormatted: '6,206',
|
||||
incomeTrend: '↓368',
|
||||
incomeTrendDir: 'down',
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
pinnedTasks: [] as EnrichedTask[],
|
||||
normalTasks: [] as EnrichedTask[],
|
||||
abandonedTasks: [] as EnrichedTask[],
|
||||
taskCount: 0,
|
||||
bannerMetrics: [] as Array<{ label: string; value: string }>,
|
||||
bannerTitle: '',
|
||||
/* CHANGE 2026-03-13 | banner 重做:添加用户信息字段,对齐 H5 原型 */
|
||||
userName: '小燕',
|
||||
userRole: '助教',
|
||||
storeName: '广州朗朗桌球',
|
||||
avatarUrl: '/assets/images/avatar-coach.png', // MOCK 头像地址
|
||||
/* CHANGE 2026-03-13 | tagSvgMap 已移除,标签恢复 CSS 渐变实现 */
|
||||
perfData: {
|
||||
nextTierHours: 0,
|
||||
remainHours: 0,
|
||||
currentTier: 0,
|
||||
tierProgress: 0,
|
||||
filledPct: 0,
|
||||
clampedSparkPct: 0,
|
||||
ticks: [],
|
||||
shineDurMs: 1000,
|
||||
sparkDurMs: 1400,
|
||||
shineRunning: false,
|
||||
sparkRunning: false,
|
||||
basicHours: '0',
|
||||
bonusHours: '0',
|
||||
totalHours: '0',
|
||||
tierCompleted: false,
|
||||
bonusMoney: '0',
|
||||
incomeMonth: '',
|
||||
prevMonth: '',
|
||||
incomeFormatted: '0',
|
||||
incomeTrend: '',
|
||||
incomeTrendDir: 'up' as 'up' | 'down',
|
||||
} as PerfData,
|
||||
stampAnimated: false,
|
||||
hasMore: true,
|
||||
|
||||
// --- 调试面板 ---
|
||||
showDebugPanel: false,
|
||||
debugTotalHours: 87.5,
|
||||
debugBasicHours: 77.5,
|
||||
debugBonusHours: 12,
|
||||
debugPreset: -1,
|
||||
|
||||
contextMenuVisible: false,
|
||||
contextMenuX: 0,
|
||||
contextMenuY: 0,
|
||||
contextMenuTarget: {} as EnrichedTask,
|
||||
|
||||
abandonModalVisible: false,
|
||||
abandonTarget: {} as EnrichedTask,
|
||||
|
||||
noteModalVisible: false,
|
||||
noteTarget: {} as EnrichedTask,
|
||||
aiColor: 'indigo', // 随机 AI 配色,页面加载时随机选取
|
||||
},
|
||||
|
||||
_longPressed: false,
|
||||
_animTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
|
||||
onLoad() {
|
||||
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
|
||||
const aiColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
this.setData({ aiColor })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onReady() {
|
||||
// 页面渲染完成后启动动画循环
|
||||
setTimeout(() => {
|
||||
this.setData({ stampAnimated: true })
|
||||
this._startAnimLoop()
|
||||
}, 100)
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 每次显示页面时重新随机 AI 配色,并恢复动画循环
|
||||
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
|
||||
const aiColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
this.setData({ aiColor })
|
||||
const tabBar = this.getTabBar?.()
|
||||
if (tabBar) tabBar.setData({ active: 'task' })
|
||||
// 若页面从后台恢复,重启动画循环
|
||||
if (this.data.pageState === 'normal' && !this._animTimer) {
|
||||
this._startAnimLoop()
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
// 页面不可见时停止动画,节省性能
|
||||
this._stopAnimLoop()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._stopAnimLoop()
|
||||
},
|
||||
|
||||
/* ──────────────────────────────────────────────────────
|
||||
* JS 动画状态机
|
||||
* 每轮流程:
|
||||
* 1. 读当前 filledPct,重新计算 shineDurMs
|
||||
* 2. 启动高光(shineRunning=true),等 shineDurMs
|
||||
* 3. 等 SPARK_DELAY_MS(可为负,即与高光末尾重叠)
|
||||
* 4. 启动火花(sparkRunning=true),等 SPARK_DUR_MS
|
||||
* 5. 停止火花(sparkRunning=false)
|
||||
* 6. 等 NEXT_LOOP_DELAY_MS(可为负,即提前启动下轮)
|
||||
* 7. 回到第1步
|
||||
* ────────────────────────────────────────────────────── */
|
||||
_startAnimLoop() {
|
||||
this._stopAnimLoop() // 防止重复启动
|
||||
this._runAnimStep()
|
||||
},
|
||||
|
||||
_stopAnimLoop() {
|
||||
if (this._animTimer !== null) {
|
||||
clearTimeout(this._animTimer)
|
||||
this._animTimer = null
|
||||
}
|
||||
// 停止时重置动画状态
|
||||
this.setData({
|
||||
'perfData.shineRunning': false,
|
||||
'perfData.sparkRunning': false,
|
||||
})
|
||||
},
|
||||
|
||||
_runAnimStep() {
|
||||
// 每轮开始时重新读当前进度,重新计算高光时长
|
||||
const filledPct = this.data.perfData.filledPct ?? 0
|
||||
const shineDurMs = calcShineDur(filledPct)
|
||||
|
||||
console.log(`[动画] filledPct=${filledPct}% shineDurMs=${shineDurMs}ms`)
|
||||
|
||||
// 阶段1:启动高光
|
||||
this.setData({
|
||||
'perfData.shineRunning': true,
|
||||
'perfData.sparkRunning': false,
|
||||
'perfData.shineDurMs': shineDurMs,
|
||||
})
|
||||
|
||||
// 阶段2:高光结束后 + SPARK_DELAY_MS → 点亮光柱+火花
|
||||
// 若 SPARK_DELAY_MS 为负,高光还未结束就提前点火
|
||||
const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS)
|
||||
this._animTimer = setTimeout(() => {
|
||||
// 阶段3:火花迸发
|
||||
this.setData({ 'perfData.sparkRunning': true })
|
||||
|
||||
// 阶段4:火花持续 SPARK_DUR_MS 后熄灭
|
||||
this._animTimer = setTimeout(() => {
|
||||
this.setData({
|
||||
'perfData.shineRunning': false,
|
||||
'perfData.sparkRunning': false,
|
||||
})
|
||||
|
||||
// 阶段5:等 NEXT_LOOP_DELAY_MS 后启动下一轮
|
||||
const nextDelay = Math.max(0, NEXT_LOOP_DELAY_MS)
|
||||
this._animTimer = setTimeout(() => {
|
||||
this._runAnimStep()
|
||||
}, nextDelay)
|
||||
|
||||
}, SPARK_DUR_MS)
|
||||
}, sparkTriggerDelay)
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadData(() => {
|
||||
wx.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore) return
|
||||
this.setData({ hasMore: false })
|
||||
wx.showToast({ title: '没有更多了', icon: 'none' })
|
||||
},
|
||||
|
||||
loadData(cb?: () => void) {
|
||||
this.setData({ pageState: 'loading', stampAnimated: false })
|
||||
|
||||
setTimeout(() => {
|
||||
/* CHANGE 2026-03-13 | mock 数据贴近 H5 原型:7 条任务(2 置顶 + 3 一般 + 2 已放弃) */
|
||||
const allTasks: Task[] = [
|
||||
...mockTasks,
|
||||
{
|
||||
id: 'task-007',
|
||||
customerName: '孙丽',
|
||||
customerAvatar: '/assets/images/avatar-default.png',
|
||||
taskType: 'callback',
|
||||
taskTypeLabel: '客户回访',
|
||||
deadline: '2026-03-06',
|
||||
heartScore: 3.5,
|
||||
hobbies: [],
|
||||
isPinned: false,
|
||||
hasNote: false,
|
||||
status: 'abandoned',
|
||||
},
|
||||
]
|
||||
|
||||
const enriched = allTasks.map(enrichTask)
|
||||
|
||||
const pinnedTasks = enriched.filter((t) => t.isPinned && !t.isAbandoned)
|
||||
const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && t.status === 'pending')
|
||||
const abandonedTasks = enriched.filter((t) => t.isAbandoned)
|
||||
const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length
|
||||
|
||||
const perfData = buildPerfData()
|
||||
const perf = mockPerformance
|
||||
const bannerTitle = `${perf.currentTier}`
|
||||
const bannerMetrics: Array<{ label: string; value: string }> = []
|
||||
|
||||
this.setData({
|
||||
pageState: totalCount > 0 ? 'normal' : 'empty',
|
||||
pinnedTasks,
|
||||
normalTasks,
|
||||
abandonedTasks,
|
||||
taskCount: totalCount,
|
||||
bannerTitle,
|
||||
bannerMetrics,
|
||||
perfData,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
if (perfData.tierCompleted) {
|
||||
setTimeout(() => {
|
||||
this.setData({ stampAnimated: true })
|
||||
}, 300)
|
||||
}
|
||||
|
||||
cb?.()
|
||||
}, 600)
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onTaskTap(e: WechatMiniprogram.TouchEvent) {
|
||||
if (this._longPressed) {
|
||||
this._longPressed = false
|
||||
return
|
||||
}
|
||||
const { id } = e.currentTarget.dataset
|
||||
wx.navigateTo({
|
||||
url: `${DETAIL_ROUTE}?id=${id}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
onPerformanceTap() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/performance/performance',
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
onTaskLongPress(e: WechatMiniprogram.TouchEvent) {
|
||||
this._longPressed = true
|
||||
const { group, index } = e.currentTarget.dataset
|
||||
const groupKey = group as 'pinned' | 'normal' | 'abandoned'
|
||||
const groupMap: Record<string, EnrichedTask[]> = {
|
||||
pinned: this.data.pinnedTasks,
|
||||
normal: this.data.normalTasks,
|
||||
abandoned: this.data.abandonedTasks,
|
||||
}
|
||||
const target = groupMap[groupKey]?.[index]
|
||||
if (!target) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const sysInfo = wx.getSystemInfoSync()
|
||||
const menuW = 175
|
||||
const menuH = 220
|
||||
let x = touch.clientX
|
||||
let y = touch.clientY
|
||||
|
||||
if (x + menuW > sysInfo.windowWidth - 16) x = sysInfo.windowWidth - menuW - 16
|
||||
if (x < 16) x = 16
|
||||
if (y + menuH > sysInfo.windowHeight - 16) y = sysInfo.windowHeight - menuH - 16
|
||||
if (y < 16) y = 16
|
||||
|
||||
this.setData({
|
||||
contextMenuVisible: true,
|
||||
contextMenuX: x,
|
||||
contextMenuY: y,
|
||||
contextMenuTarget: target,
|
||||
})
|
||||
},
|
||||
|
||||
onCloseContextMenu() {
|
||||
this.setData({ contextMenuVisible: false })
|
||||
},
|
||||
|
||||
noop() {},
|
||||
|
||||
onCtxPin() {
|
||||
const target = this.data.contextMenuTarget
|
||||
const isPinned = !target.isPinned
|
||||
wx.showToast({
|
||||
title: isPinned ? `已置顶「${target.customerName}」` : `已取消置顶「${target.customerName}」`,
|
||||
icon: 'none',
|
||||
})
|
||||
this.setData({ contextMenuVisible: false })
|
||||
this._updateTaskPin(target.id, isPinned)
|
||||
},
|
||||
|
||||
onCtxNote() {
|
||||
const target = this.data.contextMenuTarget
|
||||
this.setData({
|
||||
contextMenuVisible: false,
|
||||
noteModalVisible: true,
|
||||
noteTarget: target,
|
||||
})
|
||||
},
|
||||
|
||||
onCtxAI() {
|
||||
const target = this.data.contextMenuTarget
|
||||
this.setData({ contextMenuVisible: false })
|
||||
wx.navigateTo({
|
||||
url: `/pages/ai-chat/ai-chat?taskId=${target.id}&customerName=${target.customerName}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
onCtxAbandon() {
|
||||
const target = this.data.contextMenuTarget
|
||||
this.setData({
|
||||
contextMenuVisible: false,
|
||||
abandonModalVisible: true,
|
||||
abandonTarget: target,
|
||||
})
|
||||
},
|
||||
|
||||
/** 放弃弹窗 - 确认 */
|
||||
onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
|
||||
const { reason } = e.detail
|
||||
const target = this.data.abandonTarget
|
||||
wx.showToast({ title: `已放弃「${target.customerName}」`, icon: 'none' })
|
||||
this.setData({ abandonModalVisible: false })
|
||||
this._updateTaskAbandon(target.id, reason)
|
||||
},
|
||||
|
||||
/** 放弃弹窗 - 取消 */
|
||||
onAbandonCancel() {
|
||||
this.setData({ abandonModalVisible: false })
|
||||
},
|
||||
|
||||
/** 长按菜单 - 取消放弃(已放弃任务) */
|
||||
onCtxCancelAbandon() {
|
||||
const target = this.data.contextMenuTarget
|
||||
this.setData({ contextMenuVisible: false })
|
||||
wx.showLoading({ title: '处理中...' })
|
||||
setTimeout(() => {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: `已取消放弃「${target.customerName}」`, icon: 'success' })
|
||||
this._updateTaskCancelAbandon(target.id)
|
||||
}, 500)
|
||||
},
|
||||
|
||||
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
|
||||
const { score, content } = e.detail
|
||||
const target = this.data.noteTarget
|
||||
wx.showToast({ title: `已保存「${target.customerName}」备注`, icon: 'success' })
|
||||
this.setData({ noteModalVisible: false })
|
||||
console.log('[note]', target.id, score, content)
|
||||
},
|
||||
|
||||
onNoteCancel() {
|
||||
this.setData({ noteModalVisible: false })
|
||||
},
|
||||
|
||||
_updateTaskPin(taskId: string, isPinned: boolean) {
|
||||
const allTasks = [
|
||||
...this.data.pinnedTasks,
|
||||
...this.data.normalTasks,
|
||||
...this.data.abandonedTasks,
|
||||
].map((t) => (t.id === taskId ? { ...t, isPinned } : t))
|
||||
|
||||
this.setData({
|
||||
pinnedTasks: allTasks.filter((t) => t.isPinned && !t.isAbandoned),
|
||||
normalTasks: allTasks.filter((t) => !t.isPinned && !t.isAbandoned),
|
||||
abandonedTasks: allTasks.filter((t) => t.isAbandoned),
|
||||
})
|
||||
},
|
||||
|
||||
_updateTaskAbandon(taskId: string, reason: string) {
|
||||
const allTasks = [
|
||||
...this.data.pinnedTasks,
|
||||
...this.data.normalTasks,
|
||||
...this.data.abandonedTasks,
|
||||
].map((t) =>
|
||||
t.id === taskId
|
||||
? { ...t, isAbandoned: true, isPinned: false, status: 'abandoned' as const, abandonReason: reason }
|
||||
: t,
|
||||
)
|
||||
|
||||
const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned)
|
||||
const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned)
|
||||
const abandonedTasks = allTasks.filter((t) => t.isAbandoned)
|
||||
|
||||
this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length })
|
||||
},
|
||||
|
||||
/** 切换调试面板 */
|
||||
toggleDebugPanel() {
|
||||
this.setData({ showDebugPanel: !this.data.showDebugPanel })
|
||||
},
|
||||
|
||||
/** 调试 - 拖动总课时滑块 */
|
||||
onDebugTotalHours(e: WechatMiniprogram.SliderChange) {
|
||||
const total = e.detail.value
|
||||
this.setData({ debugTotalHours: total, debugPreset: -1 })
|
||||
this._applyDebugHours(this.data.debugBasicHours, this.data.debugBonusHours, total)
|
||||
},
|
||||
|
||||
/** 调试 - 拖动基础课时滑块 */
|
||||
onDebugBasicHours(e: WechatMiniprogram.SliderChange) {
|
||||
const basic = e.detail.value
|
||||
const total = basic + this.data.debugBonusHours
|
||||
this.setData({ debugBasicHours: basic, debugTotalHours: total, debugPreset: -1 })
|
||||
this._applyDebugHours(basic, this.data.debugBonusHours, total)
|
||||
},
|
||||
|
||||
/** 调试 - 拖动激励课时滑块 */
|
||||
onDebugBonusHours(e: WechatMiniprogram.SliderChange) {
|
||||
const bonus = e.detail.value
|
||||
const total = this.data.debugBasicHours + bonus
|
||||
this.setData({ debugBonusHours: bonus, debugTotalHours: total, debugPreset: -1 })
|
||||
this._applyDebugHours(this.data.debugBasicHours, bonus, total)
|
||||
},
|
||||
|
||||
/** 调试 - 快速预设档位 */
|
||||
onDebugPreset(e: WechatMiniprogram.BaseEvent) {
|
||||
const preset = e.currentTarget.dataset.preset as number
|
||||
const presets: Array<{ basic: number; bonus: number; total: number }> = [
|
||||
{ basic: 45, bonus: 5, total: 50 }, // 未完成 (段0中间)
|
||||
{ basic: 90, bonus: 10, total: 100 }, // 恰好达100h
|
||||
{ basic: 115, bonus: 15, total: 130 }, // 恰好达130h
|
||||
{ basic: 145, bonus: 15, total: 160 }, // 恰好达160h
|
||||
{ basic: 195, bonus: 25, total: 220 }, // 满档220h
|
||||
]
|
||||
const p = presets[preset]
|
||||
if (!p) return
|
||||
this.setData({ debugBasicHours: p.basic, debugBonusHours: p.bonus, debugTotalHours: p.total, debugPreset: preset })
|
||||
this._applyDebugHours(p.basic, p.bonus, p.total)
|
||||
},
|
||||
|
||||
/** 调试 - 切换盖戳动画 */
|
||||
onDebugToggleStamp() {
|
||||
const completed = !this.data.perfData.tierCompleted
|
||||
this.setData({
|
||||
'perfData.tierCompleted': completed,
|
||||
stampAnimated: false,
|
||||
})
|
||||
if (completed) {
|
||||
setTimeout(() => this.setData({ stampAnimated: true }), 50)
|
||||
}
|
||||
},
|
||||
|
||||
/** 内部:根据课时数值重新计算档位进度并更新 perfData */
|
||||
_applyDebugHours(basic: number, bonus: number, total: number) {
|
||||
// 档位刻度:[0, 100, 130, 160, 190, 220]
|
||||
const tiers = [0, 100, 130, 160, 190, 220]
|
||||
let currentTier = 0
|
||||
for (let i = 1; i < tiers.length; i++) {
|
||||
if (total >= tiers[i]) currentTier = i
|
||||
else break
|
||||
}
|
||||
// 当前段内进度百分比
|
||||
const segStart = tiers[currentTier]
|
||||
const segEnd = tiers[currentTier + 1] ?? tiers[tiers.length - 1]
|
||||
const tierProgress = segEnd > segStart
|
||||
? Math.min(100, Math.round(((total - segStart) / (segEnd - segStart)) * 100))
|
||||
: 100
|
||||
const nextTierHours = tiers[currentTier + 1] ?? 220
|
||||
const remainHours = Math.max(0, nextTierHours - total)
|
||||
const tierCompleted = total >= 220
|
||||
const filledPct = Math.min(100, Math.round((total / 220) * 1000) / 10)
|
||||
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock,实际由接口返回
|
||||
this.setData({
|
||||
'perfData.totalHours': String(total),
|
||||
'perfData.basicHours': String(basic),
|
||||
'perfData.bonusHours': String(bonus),
|
||||
'perfData.currentTier': currentTier,
|
||||
'perfData.tierProgress': tierProgress,
|
||||
'perfData.filledPct': filledPct,
|
||||
'perfData.clampedSparkPct': Math.max(0, Math.min(100, filledPct)),
|
||||
'perfData.ticks': buildTicks(tierNodes, 220),
|
||||
'perfData.shineDurMs': calcShineDur(filledPct),
|
||||
'perfData.sparkDurMs': SPARK_DUR_MS,
|
||||
'perfData.nextTierHours': nextTierHours,
|
||||
'perfData.remainHours': remainHours,
|
||||
'perfData.tierCompleted': tierCompleted,
|
||||
stampAnimated: false,
|
||||
})
|
||||
// 进度变化后重启动画循环,使下一轮立即用新进度重新计算高光时长
|
||||
this._startAnimLoop()
|
||||
if (tierCompleted) {
|
||||
setTimeout(() => this.setData({ stampAnimated: true }), 50)
|
||||
}
|
||||
},
|
||||
|
||||
/** 取消放弃任务 - 将任务从已放弃列表移出至一般任务 */
|
||||
_updateTaskCancelAbandon(taskId: string) {
|
||||
const allTasks = [
|
||||
...this.data.pinnedTasks,
|
||||
...this.data.normalTasks,
|
||||
...this.data.abandonedTasks,
|
||||
].map((t) =>
|
||||
t.id === taskId
|
||||
? { ...t, isAbandoned: false, isPinned: false, status: 'pending' as const, abandonReason: undefined }
|
||||
: t,
|
||||
)
|
||||
|
||||
const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned)
|
||||
const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned)
|
||||
const abandonedTasks = allTasks.filter((t) => t.isAbandoned)
|
||||
|
||||
this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length })
|
||||
},
|
||||
})
|
||||
412
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.wxml
Normal file
412
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.wxml
Normal file
@@ -0,0 +1,412 @@
|
||||
<!-- 任务列表页 — 2026-03-13 全量重写,1:1 对齐 H5 原型 task-list.html -->
|
||||
<!-- AI_CHANGELOG
|
||||
| 日期 | Prompt | 变更 |
|
||||
|------|--------|------|
|
||||
| 2026-03-13 | 重写 task-list 页面 1:1 还原 H5 原型 | 全量重写 WXML |
|
||||
| 2026-03-13 | banner 错位重做 | 移除通用 banner 组件,页面内实现完整 banner |
|
||||
| 2026-03-13 | 背景+纹理合并 SVG | 渐变+纹理+光晕合并为 SVG |
|
||||
| 2026-03-13 | 5项修复+精确还原 | 恢复纹理CSS层、盖戳改回CSS实现、头像引用修复、abandoned标签恢复CSS灰化、全量line-height校准 |
|
||||
-->
|
||||
<view class="page-task-list">
|
||||
|
||||
<!-- ====== 顶部 Banner 区域 — 对齐 H5 .banner-bg.theme-blue.texture-aurora ====== -->
|
||||
<!-- CHANGE 2026-03-13 | banner 背景:SVG 做渐变底图 + CSS 做纹理叠加(SVG pattern 在小程序中不渲染) -->
|
||||
<view class="banner-area">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-combined.svg" mode="aspectFill" />
|
||||
<!-- 纹理层:CSS repeating-linear-gradient 实现斜线网格,SVG pattern 在小程序 image 中不生效 -->
|
||||
<view class="banner-texture"></view>
|
||||
|
||||
<!-- 用户信息区 — H5: .px-5.pt-10.pb-3 -->
|
||||
<view class="user-info-section">
|
||||
<view class="user-info-row">
|
||||
<!-- 头像 — H5: w-14 h-14 rounded-2xl bg-white/20 -->
|
||||
<view class="avatar-wrap">
|
||||
<image src="/assets/images/avatar-coach.png" mode="aspectFill" class="avatar-img" />
|
||||
</view>
|
||||
<!-- 姓名+标签+门店 -->
|
||||
<view class="user-detail">
|
||||
<view class="user-name-row">
|
||||
<text class="user-name">{{userName}}</text>
|
||||
<text class="user-role-tag">{{userRole}}</text>
|
||||
</view>
|
||||
<view class="user-store-row">
|
||||
<text class="user-store">{{storeName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 业绩进度卡片 — H5: .mx-4 > .bg-white/15.backdrop-blur-md.rounded-2xl -->
|
||||
<view class="perf-card">
|
||||
<!-- L1: 跳档提示 -->
|
||||
<view class="perf-l1">
|
||||
<view class="perf-l1-left">
|
||||
<text class="perf-label">距离{{perfData.nextTierHours}}小时仅剩</text>
|
||||
<text class="perf-accent">{{perfData.remainHours}}小时</text>
|
||||
</view>
|
||||
<view class="perf-l1-right" bindtap="onPerformanceTap">
|
||||
<text class="perf-secondary">查看详情</text>
|
||||
<t-icon name="chevron-right" size="22rpx" color="rgba(255,255,255,0.7)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- L2: 5段档位进度条组件 -->
|
||||
<view class="perf-l2">
|
||||
<perf-progress-bar
|
||||
filledPct="{{perfData.filledPct}}"
|
||||
clampedSparkPct="{{perfData.clampedSparkPct}}"
|
||||
currentTier="{{perfData.currentTier}}"
|
||||
ticks="{{perfData.ticks}}"
|
||||
shineRunning="{{perfData.shineRunning}}"
|
||||
sparkRunning="{{perfData.sparkRunning}}"
|
||||
shineDurMs="{{perfData.shineDurMs}}"
|
||||
sparkDurMs="{{perfData.sparkDurMs}}"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- L3: 课时 + 红戳 + 奖金 -->
|
||||
<view class="perf-l3">
|
||||
<view class="perf-l3-left">
|
||||
<view class="perf-hours-wrap">
|
||||
<view class="perf-hours-row">
|
||||
<text class="hours-green">{{perfData.basicHours}}</text>
|
||||
<text class="hours-sep">|</text>
|
||||
<text class="hours-yellow">{{perfData.bonusHours}}</text>
|
||||
<text class="hours-sep">|</text>
|
||||
<text class="hours-white">{{perfData.totalHours}}</text>
|
||||
</view>
|
||||
<view class="hours-label-row">
|
||||
<text class="hours-label">基础课 | 激励课 | 全部</text>
|
||||
</view>
|
||||
<!-- 红戳徽章 — SVG 实现 -->
|
||||
<image
|
||||
class="stamp-badge {{stampAnimated ? 'stamp-animate' : ''}}"
|
||||
wx:if="{{perfData.tierCompleted}}"
|
||||
src="/assets/images/stamp-badge.svg"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="perf-l3-right">
|
||||
<view class="bonus-wrap">
|
||||
<text class="bonus-amount">{{perfData.bonusMoney}}</text>
|
||||
<text class="bonus-unit">元</text>
|
||||
</view>
|
||||
<view class="bonus-label-row">
|
||||
<text class="bonus-label">达{{perfData.nextTierHours}}h即得</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- L4: 预计收入 -->
|
||||
<view class="perf-l4">
|
||||
<text class="perf-l4-label">{{perfData.incomeMonth}}预计收入 | 比{{perfData.prevMonth}}同期</text>
|
||||
<view class="perf-l4-right" bindtap="onPerformanceTap">
|
||||
<text class="income-value">¥{{perfData.incomeFormatted}}</text>
|
||||
<text class="income-trend {{perfData.incomeTrendDir === 'down' ? 'trend-down' : ''}}">{{perfData.incomeTrend}}</text>
|
||||
<t-icon name="chevron-right" size="28rpx" color="rgba(255,255,255,0.7)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ====== Loading 骨架屏 ====== -->
|
||||
<view class="state-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="loading-placeholder" wx:for="{{[1,2,3]}}" wx:key="*this">
|
||||
<view class="ph-line ph-line--title"></view>
|
||||
<view class="ph-line ph-line--body"></view>
|
||||
<view class="ph-line ph-line--short"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ====== 空状态 ====== -->
|
||||
<view class="state-empty" wx:if="{{pageState === 'empty'}}">
|
||||
<text class="empty-text">暂无待办任务</text>
|
||||
</view>
|
||||
|
||||
<!-- ====== Error 状态 ====== -->
|
||||
<view class="state-error" wx:if="{{pageState === 'error'}}">
|
||||
<text class="error-text">加载失败,请重试</text>
|
||||
<view class="retry-btn" bindtap="onRetry">
|
||||
<text class="retry-btn-text">重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ====== 任务列表区域 ====== -->
|
||||
<view class="task-section" wx:if="{{pageState === 'normal'}}">
|
||||
<!-- 标题行 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">今日 客户维护</text>
|
||||
<text class="section-count">共 {{taskCount}} 项</text>
|
||||
</view>
|
||||
|
||||
<!-- 📌 置顶区域 -->
|
||||
<view class="task-group" wx:if="{{pinnedTasks.length > 0}}">
|
||||
<view class="group-label-row">
|
||||
<text class="group-label group-label--pinned">📌 置顶</text>
|
||||
<text class="group-count">{{pinnedTasks.length}}项</text>
|
||||
</view>
|
||||
<view class="task-card-list">
|
||||
<view
|
||||
class="task-card {{item.isPinned ? 'task-card--pinned' : ''}} task-card--{{item.taskType}}"
|
||||
wx:for="{{pinnedTasks}}" wx:key="id"
|
||||
hover-class="task-card--hover" hover-stay-time="100"
|
||||
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
|
||||
data-group="pinned" data-index="{{index}}"
|
||||
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
|
||||
>
|
||||
<view class="card-body">
|
||||
<view class="card-row-1">
|
||||
<view class="task-type-tag task-type-tag--{{item.taskType}}">
|
||||
<text class="tag-text">{{item.taskTypeLabel}}</text>
|
||||
</view>
|
||||
<text class="customer-name">{{item.customerName}}</text>
|
||||
<heart-icon score="{{item.heartScore}}" size="small" />
|
||||
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
|
||||
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
|
||||
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-3">
|
||||
<ai-inline-icon color="{{aiColor}}" />
|
||||
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-arrow">
|
||||
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 一般任务区域 -->
|
||||
<view class="task-group" wx:if="{{normalTasks.length > 0}}">
|
||||
<view class="group-label-row">
|
||||
<text class="group-label group-label--normal">正常任务</text>
|
||||
<text class="group-count">{{normalTasks.length}}项</text>
|
||||
</view>
|
||||
<view class="task-card-list">
|
||||
<view
|
||||
class="task-card task-card--{{item.taskType}}"
|
||||
wx:for="{{normalTasks}}" wx:key="id"
|
||||
hover-class="task-card--hover" hover-stay-time="100"
|
||||
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
|
||||
data-group="normal" data-index="{{index}}"
|
||||
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
|
||||
>
|
||||
<view class="card-body">
|
||||
<view class="card-row-1">
|
||||
<view class="task-type-tag task-type-tag--{{item.taskType}}">
|
||||
<text class="tag-text">{{item.taskTypeLabel}}</text>
|
||||
</view>
|
||||
<text class="customer-name">{{item.customerName}}</text>
|
||||
<heart-icon score="{{item.heartScore}}" size="small" />
|
||||
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
|
||||
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
|
||||
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-3">
|
||||
<ai-inline-icon color="{{aiColor}}" />
|
||||
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-arrow">
|
||||
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已放弃区域 -->
|
||||
<view class="task-group" wx:if="{{abandonedTasks.length > 0}}">
|
||||
<view class="group-label-row">
|
||||
<text class="group-label group-label--abandoned">已放弃</text>
|
||||
<text class="group-count">{{abandonedTasks.length}}项</text>
|
||||
</view>
|
||||
<view class="task-card-list">
|
||||
<view
|
||||
class="task-card task-card--abandoned"
|
||||
wx:for="{{abandonedTasks}}" wx:key="id"
|
||||
hover-class="task-card--hover" hover-stay-time="100"
|
||||
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
|
||||
data-group="abandoned" data-index="{{index}}"
|
||||
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
|
||||
>
|
||||
<view class="card-body">
|
||||
<view class="card-row-1">
|
||||
<!-- CHANGE 2026-03-13 | abandoned 标签保留原始类型标签,通过 CSS 灰化(对齐 H5 行为) -->
|
||||
<view class="task-type-tag task-type-tag--{{item.taskType}} task-type-tag--abandoned">
|
||||
<text class="tag-text">{{item.taskTypeLabel}}</text>
|
||||
</view>
|
||||
<text class="customer-name customer-name--abandoned">{{item.customerName}}</text>
|
||||
<heart-icon score="{{item.heartScore}}" size="small" />
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text visit-text--abandoned">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
|
||||
</view>
|
||||
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
|
||||
<text class="abandon-reason">放弃原因:{{item.abandonReason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-arrow">
|
||||
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" wx:if="{{!hasMore}}">
|
||||
<text class="load-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ====== P3: 长按上下文菜单 ====== -->
|
||||
<view class="ctx-overlay {{contextMenuVisible ? 'ctx-overlay--active' : ''}}" bindtap="onCloseContextMenu"></view>
|
||||
<view class="ctx-menu {{contextMenuVisible ? 'ctx-menu--active' : ''}}"
|
||||
style="left:{{contextMenuX}}px;top:{{contextMenuY}}px" catchtap="noop">
|
||||
<!-- 已放弃任务:显示"取消放弃" -->
|
||||
<block wx:if="{{contextMenuTarget.isAbandoned}}">
|
||||
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxCancelAbandon">
|
||||
<text class="ctx-emoji">↩️</text>
|
||||
<text class="ctx-text">取消放弃</text>
|
||||
</view>
|
||||
</block>
|
||||
<!-- 一般/置顶任务:显示标准菜单 -->
|
||||
<block wx:else>
|
||||
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxPin">
|
||||
<text class="ctx-emoji">📌</text>
|
||||
<text class="ctx-text">{{contextMenuTarget.isPinned ? '取消置顶' : '置顶'}}</text>
|
||||
</view>
|
||||
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxNote">
|
||||
<text class="ctx-emoji">📝</text>
|
||||
<text class="ctx-text">备注</text>
|
||||
</view>
|
||||
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAI">
|
||||
<text class="ctx-emoji">🤖</text>
|
||||
<text class="ctx-text">问问AI助手</text>
|
||||
</view>
|
||||
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAbandon">
|
||||
<text class="ctx-emoji">🗑️</text>
|
||||
<text class="ctx-text">放弃任务</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- ====== P4: 放弃弹窗 ====== -->
|
||||
<abandon-modal
|
||||
visible="{{abandonModalVisible}}"
|
||||
customerName="{{abandonTarget.customerName || ''}}"
|
||||
bind:confirm="onAbandonConfirm"
|
||||
bind:cancel="onAbandonCancel"
|
||||
/>
|
||||
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal
|
||||
visible="{{noteModalVisible}}"
|
||||
customerName="{{noteTarget.customerName || ''}}"
|
||||
showExpandBtn="{{true}}"
|
||||
showRating="{{true}}"
|
||||
bind:confirm="onNoteConfirm"
|
||||
bind:cancel="onNoteCancel"
|
||||
/>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
|
||||
<!-- 开发调试 FAB -->
|
||||
<dev-fab />
|
||||
|
||||
<!-- ====== 调试面板 ====== -->
|
||||
<view class="debug-panel {{showDebugPanel ? 'debug-panel--visible' : ''}}" catchtap="noop">
|
||||
<view class="debug-header">
|
||||
<text class="debug-title">🔧 调试工具</text>
|
||||
<view class="debug-close" bindtap="toggleDebugPanel" hover-class="debug-close--hover">
|
||||
<t-icon name="close" size="32rpx" color="#5e5e5e" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 课时进度控制 -->
|
||||
<view class="debug-section">
|
||||
<view class="debug-label-row">
|
||||
<text class="debug-label">📊 当前课时总量:</text>
|
||||
<text class="debug-value-chip">{{debugTotalHours}}h</text>
|
||||
</view>
|
||||
<slider
|
||||
class="debug-slider"
|
||||
min="0" max="220" step="1"
|
||||
value="{{debugTotalHours}}"
|
||||
bindchange="onDebugTotalHours"
|
||||
activeColor="#10b981"
|
||||
backgroundColor="#e7e7e7"
|
||||
block-size="24"
|
||||
/>
|
||||
<view class="debug-tick-row">
|
||||
<text class="debug-tick">0</text>
|
||||
<text class="debug-tick debug-tick--key">100</text>
|
||||
<text class="debug-tick debug-tick--key debug-tick--current">130</text>
|
||||
<text class="debug-tick debug-tick--key">160</text>
|
||||
<text class="debug-tick debug-tick--key">190</text>
|
||||
<text class="debug-tick">220</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 基础 / 激励课时分配 -->
|
||||
<view class="debug-section">
|
||||
<view class="debug-label-row">
|
||||
<text class="debug-label">🟢 基础课时:</text>
|
||||
<text class="debug-value-chip debug-chip--green">{{debugBasicHours}}h</text>
|
||||
</view>
|
||||
<slider
|
||||
class="debug-slider"
|
||||
min="0" max="220" step="0.5"
|
||||
value="{{debugBasicHours}}"
|
||||
bindchange="onDebugBasicHours"
|
||||
activeColor="#6ee7b7"
|
||||
backgroundColor="#e7e7e7"
|
||||
block-size="22"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="debug-section">
|
||||
<view class="debug-label-row">
|
||||
<text class="debug-label">🟡 激励课时:</text>
|
||||
<text class="debug-value-chip debug-chip--yellow">{{debugBonusHours}}h</text>
|
||||
</view>
|
||||
<slider
|
||||
class="debug-slider"
|
||||
min="0" max="80" step="0.5"
|
||||
value="{{debugBonusHours}}"
|
||||
bindchange="onDebugBonusHours"
|
||||
activeColor="#fbbf24"
|
||||
backgroundColor="#e7e7e7"
|
||||
block-size="22"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 档位进度预设 -->
|
||||
<view class="debug-section">
|
||||
<text class="debug-label">🎯 快速预设档位:</text>
|
||||
<view class="debug-btn-group">
|
||||
<view class="debug-btn {{debugPreset === 0 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="0">未完成</view>
|
||||
<view class="debug-btn {{debugPreset === 1 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="1">达100h</view>
|
||||
<view class="debug-btn {{debugPreset === 2 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="2">达130h</view>
|
||||
<view class="debug-btn {{debugPreset === 3 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="3">达160h</view>
|
||||
<view class="debug-btn {{debugPreset === 4 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="4">满档220h</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 调试触发按钮 -->
|
||||
<view class="debug-trigger {{showDebugPanel ? 'debug-trigger--active' : ''}}" bindtap="toggleDebugPanel" hover-class="debug-trigger--hover">
|
||||
<text class="debug-trigger-icon">🔧</text>
|
||||
</view>
|
||||
</view>
|
||||
1415
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.wxss
Normal file
1415
apps/DEMO-miniprogram/miniprogram/pages/task-list/task-list.wxss
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user