feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
Component({
|
||||
properties: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
customerName: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
content: '',
|
||||
error: false,
|
||||
keyboardHeight: 0,
|
||||
canSave: false,
|
||||
},
|
||||
|
||||
observers: {
|
||||
visible(val: boolean) {
|
||||
if (val) {
|
||||
// 打开弹窗时重置
|
||||
this.setData({
|
||||
content: '',
|
||||
error: false,
|
||||
keyboardHeight: 0,
|
||||
canSave: false,
|
||||
})
|
||||
} else {
|
||||
// 关闭时重置键盘高度
|
||||
this.setData({ keyboardHeight: 0 })
|
||||
}
|
||||
},
|
||||
// 内容变化时重新计算 canSave
|
||||
content(val: string) {
|
||||
this.setData({
|
||||
canSave: val.trim().length > 0,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** 文本输入 */
|
||||
onContentInput(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ content: e.detail.value, error: false })
|
||||
},
|
||||
|
||||
/** 键盘弹出 */
|
||||
onTextareaFocus(e: WechatMiniprogram.InputEvent) {
|
||||
let height = (e as any).detail?.height ?? 0
|
||||
// 修复:首次激活时键盘高度可能为0,需要设置最小值确保弹窗移动
|
||||
if (height === 0) {
|
||||
height = 260 // 微信小程序默认键盘高度约 260px
|
||||
}
|
||||
this.setData({ keyboardHeight: height })
|
||||
},
|
||||
|
||||
/** 键盘收起 */
|
||||
onTextareaBlur() {
|
||||
this.setData({ keyboardHeight: 0 })
|
||||
},
|
||||
|
||||
/** 确认放弃 */
|
||||
onConfirm() {
|
||||
if (!this.data.canSave) {
|
||||
this.setData({ error: true })
|
||||
return
|
||||
}
|
||||
|
||||
this.triggerEvent('confirm', {
|
||||
reason: this.data.content.trim(),
|
||||
})
|
||||
},
|
||||
|
||||
/** 取消 */
|
||||
onCancel() {
|
||||
this.triggerEvent('cancel')
|
||||
},
|
||||
|
||||
/** 阻止冒泡 */
|
||||
noop() {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<view class="modal-overlay {{keyboardHeight > 0 ? 'modal-overlay--keyboard-open' : ''}}" wx:if="{{visible}}" catchtap="onCancel" catchtouchmove="noop">
|
||||
<view class="modal-container" catchtap="noop">
|
||||
<!-- 头部 -->
|
||||
<view class="modal-header">
|
||||
<view class="header-left">
|
||||
<text class="modal-emoji">⚠️</text>
|
||||
<text class="modal-title">放弃 <text class="modal-name">{{customerName}}</text></text>
|
||||
</view>
|
||||
<view class="modal-close" bindtap="onCancel" hover-class="modal-close--hover">
|
||||
<t-icon name="close" size="40rpx" color="#8b8b8b" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 描述 -->
|
||||
<view class="modal-desc-wrap">
|
||||
<text class="modal-desc">确定放弃该客户的维护任务?请填写原因:</text>
|
||||
</view>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<view class="textarea-section">
|
||||
<textarea
|
||||
class="abandon-textarea"
|
||||
placeholder="请输入放弃原因(必填)"
|
||||
value="{{content}}"
|
||||
bindinput="onContentInput"
|
||||
bindfocus="onTextareaFocus"
|
||||
bindblur="onTextareaBlur"
|
||||
maxlength="200"
|
||||
auto-height
|
||||
adjust-position="{{false}}"
|
||||
placeholder-class="textarea-placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<view class="error-wrap" wx:if="{{error}}">
|
||||
<text class="error-text">请输入放弃原因后再提交</text>
|
||||
</view>
|
||||
|
||||
<!-- 键盘弹出时的占位,防止内容被遮挡 -->
|
||||
<view wx:if="{{keyboardHeight > 0}}" style="height: {{keyboardHeight}}px;"></view>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="modal-footer {{keyboardHeight > 0 ? 'modal-footer--float' : ''}}" style="{{keyboardHeight > 0 ? 'bottom: ' + keyboardHeight + 'px;' : ''}}">
|
||||
<view class="confirm-btn {{!canSave ? 'disabled' : ''}}" bindtap="onConfirm" hover-class="{{canSave ? 'confirm-btn--hover' : ''}}">
|
||||
<text class="confirm-text">确认放弃</text>
|
||||
</view>
|
||||
<view class="cancel-btn" bindtap="onCancel" hover-class="cancel-btn--hover">
|
||||
<text class="cancel-text">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,192 @@
|
||||
/* 放弃弹窗样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 1000;
|
||||
transition: align-items 0.3s ease;
|
||||
}
|
||||
|
||||
/* 键盘弹出时,弹窗移到顶部 */
|
||||
.modal-overlay--keyboard-open {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 48rpx 48rpx 0 0;
|
||||
padding: 40rpx 40rpx 60rpx;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
transition: border-radius 0.3s ease;
|
||||
}
|
||||
|
||||
/* 键盘弹出时,改为全圆角 */
|
||||
.modal-overlay--keyboard-open .modal-container {
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.modal-emoji {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.modal-name {
|
||||
color: #e34d59;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-close--hover {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.modal-desc-wrap {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
/* 文本输入 */
|
||||
.textarea-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.abandon-textarea {
|
||||
width: 100%;
|
||||
min-height: 240rpx;
|
||||
padding: 24rpx;
|
||||
background: #f3f3f3;
|
||||
border-radius: 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
color: #242424;
|
||||
border: 2rpx solid #f3f3f3;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.abandon-textarea:focus {
|
||||
border-color: #0052d9;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.textarea-placeholder {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.error-wrap {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
color: #e34d59;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* 键盘弹出时固定在键盘上方 */
|
||||
.modal-footer--float {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12rpx 40rpx 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e34d59, #f87171);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(227, 77, 89, 0.3);
|
||||
}
|
||||
|
||||
.confirm-btn.disabled {
|
||||
background: #e7e7e7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirm-btn.disabled .confirm-text {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.confirm-btn--hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.cancel-btn--hover .cancel-text {
|
||||
color: #5e5e5e;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 是否显示 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
/** 跳转目标页面 */
|
||||
targetUrl: {
|
||||
type: String,
|
||||
value: '/pages/chat/chat',
|
||||
},
|
||||
/** 可选:携带客户 ID 参数 */
|
||||
customerId: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 距底部距离(rpx),TabBar 页面用 200,非 TabBar 页面用 120 */
|
||||
bottom: {
|
||||
type: Number,
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap() {
|
||||
let url = this.data.targetUrl
|
||||
if (this.data.customerId) {
|
||||
url += `?customerId=${this.data.customerId}`
|
||||
}
|
||||
wx.navigateTo({
|
||||
url,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- AI 悬浮对话按钮 — SVG 机器人 + 渐变流动背景 -->
|
||||
<view
|
||||
class="ai-float-btn-container"
|
||||
wx:if="{{visible}}"
|
||||
style="bottom: {{bottom}}rpx"
|
||||
bindtap="onTap"
|
||||
>
|
||||
<view class="ai-float-btn">
|
||||
<!-- 高光叠加层 -->
|
||||
<view class="ai-float-btn-highlight"></view>
|
||||
<!-- 机器人 SVG(小程序用 image 引用) -->
|
||||
<image class="ai-icon-svg" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,56 @@
|
||||
/* AI 悬浮按钮 — 忠于 H5 原型:渐变流动背景 + 机器人 SVG */
|
||||
/* H5: 56px → 56×2×0.875 = 98rpx */
|
||||
|
||||
.ai-float-btn-container {
|
||||
position: fixed;
|
||||
right: 28rpx;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-float-btn {
|
||||
width: 98rpx;
|
||||
height: 98rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 渐变动画背景 — 忠于 H5 原型 */
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #f5576c 75%, #667eea 100%);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 8s ease infinite;
|
||||
box-shadow: 0 8rpx 40rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.ai-float-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 高光叠加层 */
|
||||
.ai-float-btn-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.2) 0%, transparent 50%, rgba(0,0,0,0.1) 100%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 背景渐变流动动画 */
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
25% { background-position: 50% 100%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
75% { background-position: 50% 0%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* SVG 图标 H5: 30px → 52rpx */
|
||||
.ai-icon-svg {
|
||||
height: 60rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
Component({
|
||||
options: {
|
||||
styleIsolation: 'shared',
|
||||
},
|
||||
|
||||
/**
|
||||
* ai-inline-icon — 行首 AI 小图标组件
|
||||
*
|
||||
* ## 用法
|
||||
* 1. 在页面/组件的 .json 中注册:
|
||||
* "ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon"
|
||||
*
|
||||
* 2. 在 WXML 中使用:
|
||||
* <!-- 固定配色 -->
|
||||
* <ai-inline-icon color="indigo" />
|
||||
*
|
||||
* <!-- 页面随机配色(推荐) -->
|
||||
* <ai-inline-icon color="{{aiColor}}" />
|
||||
*
|
||||
* ## color 可选值
|
||||
* red | orange | yellow | blue | indigo(默认)| purple
|
||||
*
|
||||
* ## 样式来源
|
||||
* 全局 app.wxss:.ai-inline-icon + .ai-color-*
|
||||
*
|
||||
* ## 页面随机配色初始化(复制到 Page.onLoad)
|
||||
* const AI_COLORS = ['red','orange','yellow','blue','indigo','purple']
|
||||
* const aiColor = AI_COLORS[Math.floor(Math.random() * AI_COLORS.length)]
|
||||
* this.setData({ aiColor })
|
||||
*/
|
||||
properties: {
|
||||
/** 颜色系列:red | orange | yellow | blue | indigo | purple */
|
||||
color: {
|
||||
type: String,
|
||||
value: 'indigo',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
<view class="ai-inline-icon ai-color-{{color}}">
|
||||
<image class="ai-inline-icon-img" src="/assets/icons/ai-robot-inline.svg" mode="aspectFit" />
|
||||
</view>
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 引入全局 AI 样式 */
|
||||
@import "../../app.wxss";
|
||||
|
||||
/* 组件内样式继承全局定义 */
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
Component({
|
||||
options: {
|
||||
styleIsolation: 'shared',
|
||||
},
|
||||
|
||||
/**
|
||||
* ai-title-badge — 标题行 AI 徽章组件
|
||||
*
|
||||
* ## 用法
|
||||
* 1. 在页面/组件的 .json 中注册:
|
||||
* "ai-title-badge": "/components/ai-title-badge/ai-title-badge"
|
||||
*
|
||||
* 2. 在 WXML 中使用:
|
||||
* <!-- 固定配色,默认文字 -->
|
||||
* <ai-title-badge color="indigo" />
|
||||
*
|
||||
* <!-- 自定义文字 -->
|
||||
* <ai-title-badge color="blue" label="AI 建议" />
|
||||
*
|
||||
* <!-- 页面随机配色(推荐) -->
|
||||
* <ai-title-badge color="{{aiColor}}" />
|
||||
*
|
||||
* ## color 可选值
|
||||
* red | orange | yellow | blue | indigo(默认)| purple
|
||||
*
|
||||
* ## label 默认值
|
||||
* "AI智能洞察"
|
||||
*
|
||||
* ## 样式来源
|
||||
* 全局 app.wxss:.ai-title-badge + .ai-color-*
|
||||
*
|
||||
* ## 页面随机配色初始化(复制到 Page.onLoad)
|
||||
* const AI_COLORS = ['red','orange','yellow','blue','indigo','purple']
|
||||
* const aiColor = AI_COLORS[Math.floor(Math.random() * AI_COLORS.length)]
|
||||
* this.setData({ aiColor })
|
||||
*/
|
||||
properties: {
|
||||
/** 颜色系列:red | orange | yellow | blue | indigo | purple */
|
||||
color: {
|
||||
type: String,
|
||||
value: 'indigo',
|
||||
},
|
||||
/** 徽章文字,默认「AI智能洞察」 */
|
||||
label: {
|
||||
type: String,
|
||||
value: 'AI智能洞察',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
<view class="ai-title-badge ai-color-{{color}}">
|
||||
<view class="ai-title-badge-icon">
|
||||
<image class="ai-title-badge-icon-img" src="/assets/icons/ai-robot-badge.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text>{{label}}</text>
|
||||
</view>
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 引入全局 AI 样式 */
|
||||
@import "../../app.wxss";
|
||||
|
||||
/* 组件内样式继承全局定义 */
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
26
tmp/DEMO-miniprogram/miniprogram/components/banner/banner.ts
Normal file
26
tmp/DEMO-miniprogram/miniprogram/components/banner/banner.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** Banner 主题色 */
|
||||
theme: {
|
||||
type: String,
|
||||
value: 'blue',
|
||||
},
|
||||
/** Banner 标题 */
|
||||
title: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 指标列表 [{label, value}] */
|
||||
metrics: {
|
||||
type: Array,
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** 背景图加载失败时降级为纯渐变色(CSS 已处理) */
|
||||
onBgError() {
|
||||
// 背景图加载失败,CSS 渐变色自动降级,无需额外处理
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
<view class="banner banner--{{theme}}">
|
||||
<view class="banner-bg"></view>
|
||||
<view class="banner-overlay">
|
||||
<text class="banner-title">{{title}}</text>
|
||||
<view class="banner-metrics">
|
||||
<view class="metric-item" wx:for="{{metrics}}" wx:key="label">
|
||||
<text class="metric-value">{{item.value}}</text>
|
||||
<text class="metric-label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,66 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 主题渐变降级(背景图加载失败时) */
|
||||
.banner--blue { background: linear-gradient(135deg, #0052d9, #0080ff); }
|
||||
.banner--red { background: linear-gradient(135deg, #e34d59, #ff6b6b); }
|
||||
.banner--orange { background: linear-gradient(135deg, #ed7b2f, #ffaa44); }
|
||||
.banner--pink { background: linear-gradient(135deg, #d94da0, #ff6bcc); }
|
||||
.banner--teal { background: linear-gradient(135deg, #00a870, #00d68f); }
|
||||
.banner--coral { background: linear-gradient(135deg, #e06c5a, #ff8a7a); }
|
||||
.banner--dark-gold { background: linear-gradient(135deg, #8b6914, #c9a227); }
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.banner-metrics {
|
||||
display: flex;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: var(--font-xs);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 自定义底部导航栏 — 非 TabBar 页面模拟系统导航
|
||||
Component({
|
||||
properties: {
|
||||
/** 当前激活的 tab: task / board / my */
|
||||
active: {
|
||||
type: String,
|
||||
value: 'board',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const tab = e.currentTarget.dataset.tab as string
|
||||
if (tab === this.data.active) return
|
||||
|
||||
if (tab === 'task') {
|
||||
wx.switchTab({ url: '/pages/task-list/task-list' })
|
||||
} else if (tab === 'board') {
|
||||
wx.switchTab({ url: '/pages/board-finance/board-finance' })
|
||||
} else if (tab === 'my') {
|
||||
wx.switchTab({ url: '/pages/my-profile/my-profile' })
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- 自定义底部导航栏 — 用于非 TabBar 的看板子页面,SVG icon 忠于 H5 原型 -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
class="tab-bar-item {{active === 'task' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="task"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'task' ? '/assets/icons/tab-task-nav-active.svg' : '/assets/icons/tab-task-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">任务</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-bar-item {{active === 'board' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="board"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'board' ? '/assets/icons/tab-board-nav-active.svg' : '/assets/icons/tab-board-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">看板</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-bar-item {{active === 'my' ? 'tab-bar-item--active' : ''}}"
|
||||
bindtap="onTap"
|
||||
data-tab="my"
|
||||
>
|
||||
<image
|
||||
class="tab-bar-icon"
|
||||
src="{{active === 'my' ? '/assets/icons/tab-my-nav-active.svg' : '/assets/icons/tab-my-nav.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="tab-bar-label">我的</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 自定义底部导航栏 — 模拟系统 TabBar 外观 */
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
height: 100rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.tab-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.tab-bar-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
.tab-bar-label {
|
||||
font-size: 20rpx;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
.tab-bar-item--active .tab-bar-label {
|
||||
color: #0052d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
Component({
|
||||
properties: {
|
||||
tag: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
value: 'primary',
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
data: {},
|
||||
methods: {},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
<view class="clue-item">
|
||||
<view class="clue-tag clue-tag-{{category}}">
|
||||
<text class="clue-tag-text">{{tag}}</text>
|
||||
</view>
|
||||
<view class="clue-content">
|
||||
<view class="clue-text-container">
|
||||
<text class="clue-text"><text class="clue-emoji">{{emoji}}</text> {{title}}</text>
|
||||
<text class="clue-source">{{source}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="clue-desc" wx:if="{{content}}">
|
||||
<text class="clue-desc-text">{{content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,122 @@
|
||||
.clue-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx;
|
||||
background: #fafafa;
|
||||
border-radius: 22rpx;
|
||||
border: 2rpx solid var(--color-gray-4);
|
||||
}
|
||||
|
||||
.clue-tag {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clue-tag-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* VI 规范 2.1 六种客户标签配色 */
|
||||
/* 客户基础 — 蓝色 */
|
||||
.clue-tag-primary {
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
/* 消费习惯 — 绿色 */
|
||||
.clue-tag-success {
|
||||
background: rgba(0, 168, 112, 0.10);
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
/* 玩法偏好 — 橙色 */
|
||||
.clue-tag-orange {
|
||||
background: rgba(237, 123, 47, 0.12);
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
/* 促销偏好 — 金色 */
|
||||
.clue-tag-gold {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #d4920a;
|
||||
}
|
||||
|
||||
/* 社交关系 — 紫色 */
|
||||
.clue-tag-purple {
|
||||
background: rgba(123, 97, 255, 0.10);
|
||||
color: #7b61ff;
|
||||
}
|
||||
|
||||
/* 重要反馈 — 红色 */
|
||||
.clue-tag-error {
|
||||
background: rgba(227, 77, 89, 0.10);
|
||||
color: #e34d59;
|
||||
}
|
||||
|
||||
/* 粉色(社交关系别名) */
|
||||
.clue-tag-pink {
|
||||
background: rgba(233, 30, 99, 0.10);
|
||||
color: #e91e63;
|
||||
}
|
||||
|
||||
/* warning 别名(促销偏好) */
|
||||
.clue-tag-warning {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #d4920a;
|
||||
}
|
||||
|
||||
.clue-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clue-text-container {
|
||||
position: relative;
|
||||
height: 72rpx;
|
||||
overflow: hidden;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.clue-emoji {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.clue-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
color: var(--color-gray-13);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.clue-source {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 36rpx;
|
||||
color: var(--color-gray-7);
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(to left, #fafafa 70%, transparent);
|
||||
padding-left: 50rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.clue-desc {
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.clue-desc-text {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-gray-9);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* coach-level-tag 助教等级标签组件
|
||||
* 基于 VI 设计系统 §5 助教等级配色
|
||||
*
|
||||
* 用法:
|
||||
* <coach-level-tag level="star" />
|
||||
* <coach-level-tag level="{{detail.level}}" bgColor="rgba(255,255,255,0.15)" shadowColor="rgba(0,0,0,0.6)" />
|
||||
*
|
||||
* 属性:
|
||||
* level: 'star' | 'senior' | 'middle' | 'junior'(英文 key)
|
||||
* bgColor: 半透明背景色,默认 '' (使用 CSS 各等级默认色)
|
||||
* shadowColor: 文字阴影颜色,默认 '' (使用 CSS 默认 rgba(0,0,0,1))
|
||||
*/
|
||||
|
||||
const LEVEL_MAP: Record<string, { cls: string; label: string }> = {
|
||||
star: { cls: 'level-star', label: '⭐ 星级' },
|
||||
senior: { cls: 'level-senior', label: '高级' },
|
||||
middle: { cls: 'level-middle', label: '中级' },
|
||||
junior: { cls: 'level-junior', label: '初级' },
|
||||
// 兼容旧中文 key,过渡期保留
|
||||
'星级': { cls: 'level-star', label: '⭐ 星级' },
|
||||
'高级': { cls: 'level-senior', label: '高级' },
|
||||
'中级': { cls: 'level-middle', label: '中级' },
|
||||
'初级': { cls: 'level-junior', label: '初级' },
|
||||
}
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
/** 等级 key:star | senior | middle | junior */
|
||||
level: {
|
||||
type: String,
|
||||
value: '',
|
||||
observer(this: any, val: string) {
|
||||
this._updateStyle()
|
||||
},
|
||||
},
|
||||
/** 自定义半透明背景色,例如 "rgba(255,255,255,0.15)",留空使用默认 */
|
||||
bgColor: {
|
||||
type: String,
|
||||
value: '',
|
||||
observer(this: any) {
|
||||
this._updateStyle()
|
||||
},
|
||||
},
|
||||
/** 自定义文字阴影颜色,例如 "rgba(0,0,0,0.4)",留空使用默认 */
|
||||
shadowColor: {
|
||||
type: String,
|
||||
value: '',
|
||||
observer(this: any) {
|
||||
this._updateStyle()
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
cls: 'level-junior',
|
||||
label: '',
|
||||
tagStyle: '',
|
||||
},
|
||||
|
||||
methods: {
|
||||
_updateStyle(this: any) {
|
||||
const val = this.data.level
|
||||
const info = LEVEL_MAP[val] ?? { cls: 'level-junior', label: val }
|
||||
const styles: string[] = []
|
||||
if (this.data.bgColor) styles.push(`background:${this.data.bgColor}`)
|
||||
if (this.data.shadowColor) styles.push(`text-shadow:0 0 6rpx ${this.data.shadowColor}`)
|
||||
this.setData({
|
||||
cls: info.cls,
|
||||
label: info.label,
|
||||
tagStyle: styles.join(';'),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached(this: any) {
|
||||
this._updateStyle()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- coach-level-tag 助教等级标签 -->
|
||||
<view class="coach-level-tag-v">
|
||||
<text class="coach-level-tag {{cls}}" style="{{tagStyle}}">{{label}}</text>
|
||||
</view>
|
||||
@@ -0,0 +1,42 @@
|
||||
/* coach-level-tag 助教等级标签组件样式
|
||||
* 基于 VI 设计系统 §5 助教等级配色
|
||||
*/
|
||||
|
||||
.coach-level-tag-v {
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.coach-level-tag {
|
||||
display: inline-block;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
font-size: 22rpx;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 6rpx rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
/* ⭐ 星级 — 金黄色 */
|
||||
.level-star {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
/* 高级 — 粉色 */
|
||||
.level-senior {
|
||||
color: #e91e63;
|
||||
background: rgba(233, 30, 99, 0.12);
|
||||
}
|
||||
|
||||
/* 中级 — 橙色 */
|
||||
.level-middle {
|
||||
color: #ed7b2f;
|
||||
background: rgba(237, 123, 47, 0.12);
|
||||
}
|
||||
|
||||
/* 初级 — 蓝色 */
|
||||
.level-junior {
|
||||
color: #0052d9;
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 开发调试浮动按钮组件
|
||||
*
|
||||
* 仅在 develop 环境下显示,点击跳转到 dev-tools 页面。
|
||||
* 使用 movable-view 实现可拖拽。
|
||||
*/
|
||||
Component({
|
||||
data: {
|
||||
visible: false,
|
||||
x: 580, // 初始位置:右下角附近(rpx 换算后的 px 近似值)
|
||||
y: 1100,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
// 仅 develop 环境显示
|
||||
const accountInfo = wx.getAccountInfoSync()
|
||||
const env = accountInfo.miniProgram.envVersion
|
||||
this.setData({ visible: env === "develop" })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
goDevTools() {
|
||||
wx.navigateTo({ url: "/pages/dev-tools/dev-tools" })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
开发调试浮动按钮 — 仅 develop 环境渲染
|
||||
可拖拽,点击跳转到 dev-tools 页面
|
||||
-->
|
||||
<movable-area wx:if="{{visible}}" class="fab-area">
|
||||
<movable-view
|
||||
class="fab-btn"
|
||||
direction="all"
|
||||
x="{{x}}"
|
||||
y="{{y}}"
|
||||
bindtap="goDevTools"
|
||||
>
|
||||
<text class="fab-icon">🛠</text>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
@@ -0,0 +1,29 @@
|
||||
/* 浮动按钮覆盖全屏,不阻挡页面交互 */
|
||||
.fab-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(24, 144, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 40rpx;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
width: 96rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 筛选标签文字 */
|
||||
label: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 选项列表 */
|
||||
options: {
|
||||
type: Array,
|
||||
value: [] as Array<{ value: string; text: string }>,
|
||||
},
|
||||
/** 当前选中值 */
|
||||
value: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
'value, options'(val: string, opts: Array<{ value: string; text: string }>) {
|
||||
const matched = (opts || []).find((o) => o.value === val)
|
||||
this.setData({ selectedText: matched ? matched.text : '' })
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
expanded: false,
|
||||
selectedText: '',
|
||||
panelTop: 0,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const { value, options } = this.data
|
||||
const matched = (options as Array<{ value: string; text: string }>).find(
|
||||
(o) => o.value === value,
|
||||
)
|
||||
this.setData({ selectedText: matched ? matched.text : '' })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
if (!this.data.options || (this.data.options as any[]).length === 0) return
|
||||
|
||||
if (!this.data.expanded) {
|
||||
// 展开时计算按钮底部位置,作为面板 top
|
||||
this.createSelectorQuery()
|
||||
.select('.filter-dropdown')
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect) {
|
||||
this.setData({
|
||||
panelTop: rect.bottom,
|
||||
expanded: true,
|
||||
})
|
||||
} else {
|
||||
this.setData({ expanded: true })
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
} else {
|
||||
this.setData({ expanded: false })
|
||||
}
|
||||
},
|
||||
|
||||
onSelect(e: WechatMiniprogram.TouchEvent) {
|
||||
const val = e.currentTarget.dataset.value as string
|
||||
const opts = this.data.options as Array<{ value: string; text: string }>
|
||||
const matched = opts.find((o) => o.value === val)
|
||||
this.setData({
|
||||
expanded: false,
|
||||
selectedText: matched ? matched.text : '',
|
||||
})
|
||||
this.triggerEvent('change', { value: val })
|
||||
},
|
||||
|
||||
/** 点击遮罩层关闭 */
|
||||
onMaskTap() {
|
||||
this.setData({ expanded: false })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- 筛选下拉组件 — 全屏宽度面板 + 遮罩层 -->
|
||||
<view class="filter-dropdown-wrap" wx:if="{{options && options.length > 0}}">
|
||||
<view class="filter-dropdown {{expanded ? 'filter-dropdown--active' : ''}} {{selectedText ? 'filter-dropdown--selected' : ''}}" bindtap="toggleDropdown">
|
||||
<text class="filter-label">{{selectedText || label}}</text>
|
||||
<t-icon name="caret-down-small" size="32rpx" class="filter-arrow {{expanded ? 'filter-arrow--up' : ''}}" />
|
||||
</view>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<view class="dropdown-mask" wx:if="{{expanded}}" catchtap="onMaskTap" />
|
||||
|
||||
<!-- 全屏宽度下拉面板 -->
|
||||
<view
|
||||
class="dropdown-panel {{expanded ? 'dropdown-panel--show' : ''}}"
|
||||
style="top: {{panelTop}}px"
|
||||
>
|
||||
<view
|
||||
class="dropdown-item {{item.value === value ? 'dropdown-item--active' : ''}}"
|
||||
wx:for="{{options}}"
|
||||
wx:key="value"
|
||||
data-value="{{item.value}}"
|
||||
bindtap="onSelect"
|
||||
>
|
||||
<text>{{item.text}}</text>
|
||||
<t-icon wx:if="{{item.value === value}}" name="check" size="32rpx" color="var(--color-primary)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,116 @@
|
||||
.filter-dropdown-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 触发按钮 */
|
||||
/* H5: px-3=12px→22rpx, py-2=8px→14rpx, bg-gray-50=#f9fafb, rounded-lg=8px→14rpx, border-gray-100=#f3f4f6 */
|
||||
/* 增加 padding 匹配 H5 按钮高度 37.33px */
|
||||
.filter-dropdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
background: #f9fafb;
|
||||
border-radius: 14rpx;
|
||||
border: 2rpx solid #f3f4f6;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-dropdown--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
/* H5: text-sm=14px→24rpx, font-medium=500 for sort label */
|
||||
.filter-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-gray-12);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-dropdown--active .filter-label {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 已选中筛选项 — 粗字体标记当前选择 */
|
||||
.filter-dropdown--selected .filter-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 箭头旋转动画 */
|
||||
.filter-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--color-gray-6);
|
||||
}
|
||||
|
||||
.filter-arrow--up {
|
||||
transform: rotate(180deg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 遮罩层 — 半透明黑色背景,忠于 H5 原型 */
|
||||
.dropdown-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 下拉面板 — 全屏宽度,固定定位,从筛选栏下方展开 */
|
||||
.dropdown-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 0 0 28rpx 28rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-16rpx);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
pointer-events: none;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.dropdown-panel--show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 选项项 — 更大的 padding,忠于 H5 原型 */
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 34rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-12, #2c2c2c);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
.dropdown-item--active {
|
||||
color: var(--color-primary, #0052d9);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dropdown-item + .dropdown-item {
|
||||
border-top: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 评分 0-10,超出范围自动 clamp */
|
||||
score: {
|
||||
type: Number,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
heartEmoji: '💙',
|
||||
},
|
||||
|
||||
observers: {
|
||||
score(val: number) {
|
||||
const s = val < 0 ? 0 : val > 10 ? 10 : val
|
||||
let emoji: string
|
||||
if (s > 8.5) {
|
||||
emoji = '💖' // 粉红色 - 很好
|
||||
} else if (s >= 6) {
|
||||
emoji = '🧡' // 橙色 - 良好
|
||||
} else if (s >= 3.5) {
|
||||
emoji = '💛' // 黄色 - 一般
|
||||
} else {
|
||||
emoji = '💙' // 蓝色 - 待发展
|
||||
}
|
||||
this.setData({ heartEmoji: emoji })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
<text class="heart-icon">{{heartEmoji}}</text>
|
||||
@@ -0,0 +1,6 @@
|
||||
.heart-icon {
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: -4rpx;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** type → Emoji + 标签文字映射 */
|
||||
const TAG_MAP: Record<string, { emoji: string; label: string }> = {
|
||||
chinese: { emoji: '🎱', label: '中式' },
|
||||
snooker: { emoji: '斯', label: '斯诺克' },
|
||||
mahjong: { emoji: '🀅', label: '麻将' },
|
||||
karaoke: { emoji: '🎤', label: 'K歌' },
|
||||
}
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
/** 喜好类型 */
|
||||
type: {
|
||||
type: String,
|
||||
value: 'chinese',
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
type(val: string) {
|
||||
const tag = TAG_MAP[val] || { emoji: '❓', label: val }
|
||||
this.setData({ emoji: tag.emoji, label: tag.label })
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
emoji: '🎱',
|
||||
label: '中式',
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const tag = TAG_MAP[this.data.type] || { emoji: '❓', label: this.data.type }
|
||||
this.setData({ emoji: tag.emoji, label: tag.label })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
<view class="hobby-tag">
|
||||
<text class="tag-emoji">{{emoji}}</text>
|
||||
<text class="tag-label">{{label}}</text>
|
||||
</view>
|
||||
@@ -0,0 +1,19 @@
|
||||
.hobby-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-gray-1);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-gray-9);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tag-emoji {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.tag-label {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 指标名称 */
|
||||
title: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 指标数值(已格式化字符串) */
|
||||
value: {
|
||||
type: String,
|
||||
value: null,
|
||||
},
|
||||
/** 单位(元/次/人) */
|
||||
unit: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 环比趋势 */
|
||||
trend: {
|
||||
type: String,
|
||||
value: 'flat', // 'up' | 'down' | 'flat'
|
||||
},
|
||||
/** 环比数值(如 "+12.5%") */
|
||||
trendValue: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 帮助说明文字 */
|
||||
helpText: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
value(val: string | null | undefined) {
|
||||
// null/undefined 显示 "--"
|
||||
this.setData({
|
||||
displayValue: val == null || val === '' ? '--' : val,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
displayValue: '--',
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const val = this.data.value
|
||||
this.setData({
|
||||
displayValue: val == null || val === '' ? '--' : val,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('tap')
|
||||
},
|
||||
onHelpTap(e: WechatMiniprogram.TouchEvent) {
|
||||
// 阻止冒泡到卡片 tap
|
||||
e.stopPropagation?.()
|
||||
this.triggerEvent('helpTap')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
<view class="metric-card" bindtap="onTap">
|
||||
<view class="metric-header">
|
||||
<text class="metric-title">{{title}}</text>
|
||||
<view class="metric-help" wx:if="{{helpText}}" catchtap="onHelpTap">
|
||||
<t-icon name="help-circle" size="32rpx" color="#a6a6a6" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metric-body">
|
||||
<text class="metric-value">{{displayValue}}</text>
|
||||
<text class="metric-unit" wx:if="{{unit}}">{{unit}}</text>
|
||||
</view>
|
||||
|
||||
<view class="metric-trend" wx:if="{{trendValue}}">
|
||||
<view class="trend-tag trend-{{trend}}">
|
||||
<t-icon wx:if="{{trend === 'up'}}" name="arrow-up" size="24rpx" />
|
||||
<t-icon wx:elif="{{trend === 'down'}}" name="arrow-down" size="24rpx" />
|
||||
<text wx:else class="trend-flat-icon">-</text>
|
||||
<text class="trend-text">{{trendValue}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,82 @@
|
||||
.metric-card {
|
||||
background: #ffffff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24rpx;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-gray-7);
|
||||
}
|
||||
|
||||
.metric-help {
|
||||
padding: 4rpx;
|
||||
}
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--color-gray-13);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-gray-7);
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
/* 上升 → 绿色 */
|
||||
.trend-up {
|
||||
color: var(--color-success);
|
||||
background: rgba(0, 168, 112, 0.08);
|
||||
}
|
||||
|
||||
/* 下降 → 红色 */
|
||||
.trend-down {
|
||||
color: var(--color-error);
|
||||
background: rgba(227, 77, 89, 0.08);
|
||||
}
|
||||
|
||||
/* 持平 → 灰色 */
|
||||
.trend-flat {
|
||||
color: var(--color-gray-7);
|
||||
background: rgba(139, 139, 139, 0.08);
|
||||
}
|
||||
|
||||
.trend-flat-icon {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
font-size: var(--font-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-rate": "tdesign-miniprogram/rate/rate",
|
||||
"t-textarea": "tdesign-miniprogram/textarea/textarea",
|
||||
"t-button": "tdesign-miniprogram/button/button",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
Component({
|
||||
properties: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
customerName: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
initialScore: {
|
||||
type: Number,
|
||||
value: 0,
|
||||
},
|
||||
initialContent: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
/** 是否显示展开/收起按钮 */
|
||||
showExpandBtn: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
/** 是否显示评分区域 */
|
||||
showRating: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
ratingExpanded: false, // 默认收起
|
||||
serviceScore: 0, // 再次服务此客户 1-5
|
||||
returnScore: 0, // 再来店可能性 1-5
|
||||
content: '',
|
||||
keyboardHeight: 0, // 键盘高度 px
|
||||
canSave: false, // 是否可保存
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
created() {
|
||||
// 初始化私有缓存(不走 setData,避免触发渲染)
|
||||
;(this as any)._heartContainerRect = null
|
||||
;(this as any)._ballContainerRect = null
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
visible(val: boolean) {
|
||||
if (val) {
|
||||
// 打开弹窗时重置
|
||||
this.setData({
|
||||
ratingExpanded: this.data.showRating && !this.data.showExpandBtn, // 只有在显示评分且无按钮时才默认展开
|
||||
serviceScore: 0,
|
||||
returnScore: 0,
|
||||
content: this.data.initialContent || '',
|
||||
keyboardHeight: 0,
|
||||
canSave: false,
|
||||
})
|
||||
// 多次重试获取容器位置信息,确保首次也能正确缓存
|
||||
this._retryGetContainerRects(0)
|
||||
} else {
|
||||
// 关闭时重置键盘高度
|
||||
this.setData({ keyboardHeight: 0 })
|
||||
}
|
||||
},
|
||||
// 任意评分或内容变化时重新计算 canSave
|
||||
'serviceScore, returnScore, content, showRating'(
|
||||
serviceScore: number,
|
||||
returnScore: number,
|
||||
content: string,
|
||||
showRating: boolean
|
||||
) {
|
||||
// 如果不显示评分,只需要有内容即可保存;否则需要评分和内容都有
|
||||
const canSave = showRating
|
||||
? serviceScore > 0 && returnScore > 0 && content.trim().length > 0
|
||||
: content.trim().length > 0
|
||||
this.setData({ canSave })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** 缓存容器位置信息 */
|
||||
_cacheContainerRects() {
|
||||
const query = this.createSelectorQuery()
|
||||
query.select('.rating-right-heart').boundingClientRect()
|
||||
query.select('.rating-right-ball').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
if (res[0]) (this as any)._heartContainerRect = res[0] as WechatMiniprogram.BoundingClientRectCallbackResult
|
||||
if (res[1]) (this as any)._ballContainerRect = res[1] as WechatMiniprogram.BoundingClientRectCallbackResult
|
||||
})
|
||||
},
|
||||
|
||||
/** 重试获取容器位置,确保首次打开也能正确缓存 */
|
||||
_retryGetContainerRects(attempt: number) {
|
||||
const maxAttempts = 5
|
||||
const delay = 50 + attempt * 50 // 50ms, 100ms, 150ms, 200ms, 250ms
|
||||
|
||||
setTimeout(() => {
|
||||
this._cacheContainerRects()
|
||||
|
||||
// 检查是否成功缓存,如果没有且还有重试次数,继续重试
|
||||
const heartRect = (this as any)._heartContainerRect
|
||||
const ballRect = (this as any)._ballContainerRect
|
||||
|
||||
if ((!heartRect || !ballRect) && attempt < maxAttempts - 1) {
|
||||
this._retryGetContainerRects(attempt + 1)
|
||||
}
|
||||
}, delay)
|
||||
},
|
||||
|
||||
/** 切换展开/收起 */
|
||||
onToggleExpand() {
|
||||
const nextExpanded = !this.data.ratingExpanded
|
||||
this.setData({ ratingExpanded: nextExpanded })
|
||||
// 展开后重新获取位置
|
||||
if (nextExpanded) {
|
||||
setTimeout(() => {
|
||||
this._cacheContainerRects()
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
|
||||
/** 爱心点击 */
|
||||
onHeartTap(e: WechatMiniprogram.BaseEvent) {
|
||||
const score = e.currentTarget.dataset.score as number
|
||||
this.setData({ serviceScore: score })
|
||||
},
|
||||
|
||||
/** 爱心区域触摸开始 */
|
||||
onHeartTouchStart(e: WechatMiniprogram.TouchEvent) {
|
||||
this._updateHeartScore(e)
|
||||
},
|
||||
|
||||
/** 爱心区域触摸移动 */
|
||||
onHeartTouchMove(e: WechatMiniprogram.TouchEvent) {
|
||||
this._updateHeartScore(e)
|
||||
},
|
||||
|
||||
/** 更新爱心评分 */
|
||||
_updateHeartScore(e: WechatMiniprogram.TouchEvent) {
|
||||
const container = (this as any)._heartContainerRect as WechatMiniprogram.BoundingClientRectCallbackResult | null
|
||||
if (!container) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
// 垂直方向超出不影响评分,仅用 X 轴计算
|
||||
let relativeX = touch.clientX - container.left
|
||||
|
||||
// 水平边界:最左=1分,最右=5分
|
||||
if (relativeX < 0) relativeX = 0
|
||||
if (relativeX > container.width) relativeX = container.width
|
||||
|
||||
// 计算分数 (1-5)
|
||||
const score = Math.ceil((relativeX / container.width) * 5)
|
||||
const finalScore = Math.max(1, Math.min(5, score))
|
||||
|
||||
if (finalScore !== this.data.serviceScore) {
|
||||
this.setData({ serviceScore: finalScore })
|
||||
}
|
||||
},
|
||||
|
||||
/** 台球点击 */
|
||||
onBallTap(e: WechatMiniprogram.BaseEvent) {
|
||||
const score = e.currentTarget.dataset.score as number
|
||||
this.setData({ returnScore: score })
|
||||
},
|
||||
|
||||
/** 台球区域触摸开始 */
|
||||
onBallTouchStart(e: WechatMiniprogram.TouchEvent) {
|
||||
this._updateBallScore(e)
|
||||
},
|
||||
|
||||
/** 台球区域触摸移动 */
|
||||
onBallTouchMove(e: WechatMiniprogram.TouchEvent) {
|
||||
this._updateBallScore(e)
|
||||
},
|
||||
|
||||
/** 更新台球评分 */
|
||||
_updateBallScore(e: WechatMiniprogram.TouchEvent) {
|
||||
const container = (this as any)._ballContainerRect as WechatMiniprogram.BoundingClientRectCallbackResult | null
|
||||
if (!container) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
// 垂直方向超出不影响评分,仅用 X 轴计算
|
||||
let relativeX = touch.clientX - container.left
|
||||
|
||||
// 水平边界:最左=1分,最右=5分
|
||||
if (relativeX < 0) relativeX = 0
|
||||
if (relativeX > container.width) relativeX = container.width
|
||||
|
||||
// 计算分数 (1-5)
|
||||
const score = Math.ceil((relativeX / container.width) * 5)
|
||||
const finalScore = Math.max(1, Math.min(5, score))
|
||||
|
||||
if (finalScore !== this.data.returnScore) {
|
||||
this.setData({ returnScore: finalScore })
|
||||
}
|
||||
},
|
||||
|
||||
/** 文本输入 */
|
||||
onContentInput(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ content: e.detail.value })
|
||||
},
|
||||
|
||||
/** 键盘弹出 */
|
||||
onTextareaFocus(e: WechatMiniprogram.InputEvent) {
|
||||
let height = (e as any).detail?.height ?? 0
|
||||
// 修复:首次激活时键盘高度可能为0,需要设置最小值确保弹窗移动
|
||||
if (height === 0) {
|
||||
height = 260 // 微信小程序默认键盘高度约 260px
|
||||
}
|
||||
this.setData({ keyboardHeight: height })
|
||||
},
|
||||
|
||||
/** 键盘收起 */
|
||||
onTextareaBlur() {
|
||||
this.setData({ keyboardHeight: 0 })
|
||||
},
|
||||
|
||||
/** 确认保存 */
|
||||
onConfirm() {
|
||||
if (!this.data.canSave) return
|
||||
|
||||
// 计算综合评分:取两个评分的平均值,转换为10分制
|
||||
const avgScore = (this.data.serviceScore + this.data.returnScore) / 2
|
||||
const finalScore = avgScore * 2 // 转换为10分制
|
||||
|
||||
this.triggerEvent('confirm', {
|
||||
score: finalScore,
|
||||
content: this.data.content.trim(),
|
||||
serviceScore: this.data.serviceScore,
|
||||
returnScore: this.data.returnScore,
|
||||
})
|
||||
},
|
||||
|
||||
/** 取消 */
|
||||
onCancel() {
|
||||
this.triggerEvent('cancel')
|
||||
},
|
||||
|
||||
/** 阻止冒泡 */
|
||||
noop() {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<view class="modal-overlay {{keyboardHeight > 0 ? 'modal-overlay--keyboard-open' : ''}}" wx:if="{{visible}}" catchtap="onCancel" catchtouchmove="noop">
|
||||
<view class="modal-container" catchtap="noop">
|
||||
<!-- 头部 -->
|
||||
<view class="modal-header">
|
||||
<view class="header-left">
|
||||
<text class="modal-title">添加备注</text>
|
||||
<view class="expand-btn" wx:if="{{showExpandBtn && showRating}}" bindtap="onToggleExpand" hover-class="expand-btn--hover">
|
||||
<text class="expand-text">{{ratingExpanded ? '收起评价 ▴' : '展开评价 ▾'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-close" bindtap="onCancel" hover-class="modal-close--hover">
|
||||
<t-icon name="close" size="40rpx" color="#8b8b8b" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评分区域 -->
|
||||
<view class="rating-section" wx:if="{{showRating && ratingExpanded}}">
|
||||
<!-- 再次服务此客户 - 爱心 -->
|
||||
<view class="rating-group">
|
||||
<text class="rating-label">再次服务此客户</text>
|
||||
<view class="rating-right rating-right-heart" bindtouchstart="onHeartTouchStart" bindtouchmove="onHeartTouchMove">
|
||||
<view class="rating-row">
|
||||
<view class="rating-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this" bindtap="onHeartTap" data-score="{{item}}" hover-class="rating-item--hover">
|
||||
<image class="rating-icon" src="{{serviceScore >= item ? '/assets/icons/heart-filled.svg' : '/assets/icons/heart-empty.svg'}}" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="rating-hints">
|
||||
<text class="hint" style="text-align: left;">不想</text>
|
||||
<text class="hint"></text>
|
||||
<text class="hint">一般</text>
|
||||
<text class="hint"></text>
|
||||
<text class="hint">非常想</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 再来店可能性 - 台球 -->
|
||||
<view class="rating-group">
|
||||
<text class="rating-label">再来店可能性</text>
|
||||
<view class="rating-right rating-right-ball" bindtouchstart="onBallTouchStart" bindtouchmove="onBallTouchMove">
|
||||
<view class="rating-row">
|
||||
<view class="rating-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this" bindtap="onBallTap" data-score="{{item}}" hover-class="rating-item--hover">
|
||||
<image class="rating-icon" src="{{returnScore >= item ? '/assets/icons/ball-black.svg' : '/assets/icons/ball-gray.svg'}}" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="rating-hints">
|
||||
<text class="hint" style="text-align: left;">不可能</text>
|
||||
<text class="hint"></text>
|
||||
<text class="hint">不好说</text>
|
||||
<text class="hint"></text>
|
||||
<text class="hint">肯定能</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<view class="textarea-section">
|
||||
<textarea
|
||||
class="note-textarea"
|
||||
placeholder="请输入备注内容..."
|
||||
value="{{content}}"
|
||||
bindinput="onContentInput"
|
||||
bindfocus="onTextareaFocus"
|
||||
bindblur="onTextareaBlur"
|
||||
maxlength="500"
|
||||
auto-height
|
||||
adjust-position="{{false}}"
|
||||
placeholder-class="textarea-placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 键盘弹出时的占位,防止内容被遮挡 -->
|
||||
<view wx:if="{{keyboardHeight > 0}}" style="height: {{keyboardHeight}}px;"></view>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="modal-footer {{keyboardHeight > 0 ? 'modal-footer--float' : ''}}" style="{{keyboardHeight > 0 ? 'bottom: ' + keyboardHeight + 'px;' : ''}}">
|
||||
<view class="save-btn {{!canSave ? 'disabled' : ''}}" bindtap="onConfirm" hover-class="{{canSave ? 'save-btn--hover' : ''}}">
|
||||
<text class="save-text">保存</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,241 @@
|
||||
/* 备注弹窗样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 1000;
|
||||
transition: align-items 0.3s ease;
|
||||
}
|
||||
|
||||
/* 键盘弹出时,弹窗移到顶部 */
|
||||
.modal-overlay--keyboard-open {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 48rpx 48rpx 0 0;
|
||||
padding: 40rpx 40rpx 60rpx;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
transition: border-radius 0.3s ease;
|
||||
}
|
||||
|
||||
/* 键盘弹出时,改为全圆角 */
|
||||
.modal-overlay--keyboard-open .modal-container {
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
padding: 6rpx 24rpx;
|
||||
background: #e8e8e8;
|
||||
border-radius: 999rpx;
|
||||
line-height: 16rpx;
|
||||
}
|
||||
|
||||
.expand-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
.expand-btn--hover {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-close--hover {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
/* 评分区域 */
|
||||
.rating-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.rating-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
background: #f3f3f3;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.rating-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 40rpx;
|
||||
color: #242424;
|
||||
padding-top: 8rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rating-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.rating-item {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
/* 移除过渡动画 */
|
||||
}
|
||||
|
||||
.rating-item--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.rating-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: block;
|
||||
/* 移除过渡动画 */
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rating-hints {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
color: #a6a6a6;
|
||||
text-align: center;
|
||||
width: 80rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint:nth-child(2n) {
|
||||
width: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 文本输入 */
|
||||
.textarea-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.note-textarea {
|
||||
width: 100%;
|
||||
min-height: 240rpx;
|
||||
padding: 24rpx;
|
||||
background: #f3f3f3;
|
||||
border-radius: 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
color: #242424;
|
||||
border: 2rpx solid #f3f3f3;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.note-textarea:focus {
|
||||
border-color: #0052d9;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.textarea-placeholder {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 键盘弹出时固定在键盘上方 */
|
||||
.modal-footer--float {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12rpx 40rpx 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
flex: 1;
|
||||
height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0052d9, #2979ff);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 82, 217, 0.3);
|
||||
}
|
||||
|
||||
.save-btn.disabled {
|
||||
background: #e7e7e7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.save-text {
|
||||
font-size: 32rpx;
|
||||
line-height: 44rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.save-btn.disabled .save-text {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.save-btn--hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 进度条填充百分比 0~100 */
|
||||
filledPct: { type: Number, value: 0 },
|
||||
/** 火星位置百分比 0~100(夹紧在进度末端) */
|
||||
clampedSparkPct: { type: Number, value: 0 },
|
||||
/** 当前档位 0~5,控制刻度高亮 */
|
||||
currentTier: { type: Number, value: 0 },
|
||||
/**
|
||||
* 刻度数组(从接口传入,不写死)
|
||||
* 每项:{ value: number, label: string, left: string, highlight?: boolean }
|
||||
* left: 百分比字符串,如 '45.45%'
|
||||
* highlight: 是否加粗高亮(如关键档位)
|
||||
*/
|
||||
ticks: { type: Array, value: [] },
|
||||
/** 高光单次播放触发 */
|
||||
shineRunning: { type: Boolean, value: false },
|
||||
/** 火花单次播放触发 */
|
||||
sparkRunning: { type: Boolean, value: false },
|
||||
/** 高光动画时长(毫秒),由页面层按进度+速度计算后传入 */
|
||||
shineDurMs: { type: Number, value: 1000 },
|
||||
/** 火花动画时长(毫秒) */
|
||||
sparkDurMs: { type: Number, value: 1400 },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- perf-progress-bar 组件
|
||||
Props:
|
||||
filledPct : number 进度条填充百分比 0~100
|
||||
clampedSparkPct : number 火星位置 0~100,夹紧在进度末端
|
||||
currentTier : number 当前档位 0~5,控制刻度高亮
|
||||
ticks : Array<{value: number, label: string, left: string}> 刻度数组(接口传入)
|
||||
shineRunning : boolean 高光单次播放触发
|
||||
sparkRunning : boolean 火花单次播放触发
|
||||
shineDurMs : number 高光动画时长(毫秒)
|
||||
sparkDurMs : number 火花动画时长(毫秒)
|
||||
-->
|
||||
<view class="ppb-wrap">
|
||||
<!-- 进度条轨道 -->
|
||||
<view class="ppb-track">
|
||||
<!-- 底轨背景 -->
|
||||
<view class="ppb-track-bg"></view>
|
||||
|
||||
<!-- 渐变填充条 -->
|
||||
<view
|
||||
class="ppb-fill"
|
||||
style="width: {{filledPct}}%"
|
||||
wx:if="{{filledPct > 0}}"
|
||||
>
|
||||
<!-- 全宽渐变层 -->
|
||||
<view class="ppb-gradient-bar"></view>
|
||||
<!-- 高光 -->
|
||||
<view
|
||||
class="ppb-shine {{shineRunning ? 'ppb-shine--active' : ''}}"
|
||||
style="animation-duration: {{shineDurMs}}ms"
|
||||
></view>
|
||||
</view>
|
||||
|
||||
<!-- 分隔竖线:由 ticks 数组动态渲染,跳过第一个(0)和最后一个 -->
|
||||
<view
|
||||
wx:for="{{ticks}}"
|
||||
wx:key="value"
|
||||
wx:if="{{index > 0 && index < ticks.length - 1}}"
|
||||
class="ppb-divider"
|
||||
style="left: {{item.left}}"
|
||||
></view>
|
||||
|
||||
<!-- 导火索火星 -->
|
||||
<view
|
||||
class="ppb-edge-glow {{sparkRunning ? 'ppb-edge-glow--active' : ''}}"
|
||||
style="left: {{clampedSparkPct}}%; animation-duration: {{sparkDurMs}}ms"
|
||||
>
|
||||
<view class="ppb-spark ppb-spark-1 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
<view class="ppb-spark ppb-spark-2 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
<view class="ppb-spark ppb-spark-3 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
<view class="ppb-spark ppb-spark-4 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
<view class="ppb-spark ppb-spark-5 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
<view class="ppb-spark ppb-spark-6 {{sparkRunning ? 'ppb-spark--active' : ''}}" style="animation-duration: {{sparkDurMs}}ms"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 刻度 -->
|
||||
<view class="ppb-ticks">
|
||||
<text
|
||||
wx:for="{{ticks}}"
|
||||
wx:key="value"
|
||||
class="ppb-tick {{currentTier >= index ? 'ppb-tick--done' : ''}} {{item.highlight ? 'ppb-tick--highlight' : ''}}"
|
||||
style="{{index === 0 ? 'left:0' : index === ticks.length-1 ? 'right:0' : 'left:' + item.left + ';transform:translateX(-50%)'}}"
|
||||
>{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,260 @@
|
||||
/* perf-progress-bar 组件样式
|
||||
* 所有 class 以 ppb- 为前缀,避免与页面样式冲突
|
||||
*/
|
||||
|
||||
/* ── 外层容器:为刻度留出底部空间 ── */
|
||||
.ppb-wrap {
|
||||
position: relative;
|
||||
padding-bottom: 10rpx; /* 为刻度行留空间 */
|
||||
}
|
||||
|
||||
/* ── 进度条轨道 ── */
|
||||
.ppb-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 14rpx;
|
||||
border-radius: 9rpx;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 底轨背景 */
|
||||
.ppb-track-bg {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border-radius: 9rpx;
|
||||
background: var(--ppb-track-bg-color, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
/* 填充条:控制裁剪宽度 */
|
||||
.ppb-fill {
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
border-radius: 9rpx;
|
||||
overflow: hidden;
|
||||
transition: width 0.4s cubic-bezier(0.34, 1.2, 0.64, 1);
|
||||
box-shadow: 0 0 14rpx rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
/* 全宽渐变层 */
|
||||
.ppb-gradient-bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: 100vw;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fde68a 0%,
|
||||
#fbbf24 20%,
|
||||
#f97316 45%,
|
||||
#ef4444 70%,
|
||||
#910a0a 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 分隔竖线 */
|
||||
.ppb-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8rpx;
|
||||
height: 100%;
|
||||
background: var(--ppb-divider-color, #5381D9);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
* 高光动画
|
||||
* ★ 外观旋钮:
|
||||
* --ppb-shine-width : 光束宽度(固定 rpx,不随进度变化)
|
||||
* --ppb-shine-opacity: 峰值亮度 0~1
|
||||
* --ppb-shine-color : RGB颜色,如 255,220,100=暖黄
|
||||
* ══════════════════════════════════════════════════════ */
|
||||
.ppb-shine {
|
||||
--ppb-shine-width: 120rpx;
|
||||
--ppb-shine-opacity: 1.0;
|
||||
--ppb-shine-color: 255, 255, 255;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -130rpx;
|
||||
width: var(--ppb-shine-width);
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(var(--ppb-shine-color), 0.08) 20%,
|
||||
rgba(var(--ppb-shine-color), var(--ppb-shine-opacity)) 50%,
|
||||
rgba(var(--ppb-shine-color), 0.08) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ppb-shine--active {
|
||||
animation: ppbShine 1s linear 1 forwards;
|
||||
}
|
||||
@keyframes ppbShine {
|
||||
0% { left: -130rpx; opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { left: calc(100% + 10rpx); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
* 导火索效果
|
||||
* ★ 外观旋钮:
|
||||
* --ppb-spark-scale : 整体缩放
|
||||
* --ppb-spark-pole-h: 光柱高度
|
||||
* ══════════════════════════════════════════════════════ */
|
||||
.ppb-edge-glow {
|
||||
--ppb-spark-scale: 0.7;
|
||||
--ppb-spark-pole-h: 30rpx;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) scale(var(--ppb-spark-scale));
|
||||
transform-origin: center center;
|
||||
width: calc(var(--ppb-spark-pole-h) / 2);
|
||||
height: var(--ppb-spark-pole-h);
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 4rpx 2rpx rgba(255, 200, 80, 0.35);
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
z-index: 10;
|
||||
transition: opacity 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.ppb-edge-glow--active {
|
||||
animation: ppbEdgePulse 1s ease-out 1 forwards;
|
||||
}
|
||||
@keyframes ppbEdgePulse {
|
||||
0% { opacity: 1; box-shadow: 0 0 22rpx 12rpx rgba(255, 255, 255, 0.95); }
|
||||
15% { opacity: 1; box-shadow: 0 0 26rpx 14rpx rgba(255, 220, 80, 0.90); }
|
||||
55% { opacity: 0.6; box-shadow: 0 0 12rpx 6rpx rgba(255, 160, 40, 0.60); }
|
||||
85% { opacity: 0.25; box-shadow: 0 0 4rpx 2rpx rgba(255, 200, 80, 0.35); }
|
||||
100% { opacity: 0.25; box-shadow: 0 0 4rpx 2rpx rgba(255, 200, 80, 0.35); }
|
||||
}
|
||||
|
||||
/* 火星粒子基础 */
|
||||
.ppb-spark {
|
||||
position: absolute;
|
||||
border-radius: 999rpx;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation: none;
|
||||
}
|
||||
.ppb-spark--active {
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
/* 粒子1:右上,亮白 */
|
||||
.ppb-spark-1 { width: 10rpx; height: 10rpx; background: #ffffff; }
|
||||
.ppb-spark-1.ppb-spark--active { animation-name: ppbSpark1; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark1 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) scale(0.0); }
|
||||
8% { opacity: 1; transform: translate( 8rpx, -16rpx) scale(1.6); }
|
||||
25% { opacity: 0.9; transform: translate( 16rpx, -30rpx) scale(1.2); }
|
||||
45% { opacity: 0.7; transform: translate( 22rpx, -40rpx) scale(0.9); }
|
||||
62% { opacity: 0.5; transform: translate( 27rpx, -48rpx) scale(0.7); }
|
||||
76% { opacity: 0.3; transform: translate( 31rpx, -53rpx) scale(0.5); }
|
||||
87% { opacity: 0.15; transform: translate( 34rpx, -57rpx) scale(0.3); }
|
||||
100% { opacity: 0; transform: translate( 36rpx, -60rpx) scale(0.1); }
|
||||
}
|
||||
|
||||
/* 粒子2:右下,橙色 */
|
||||
.ppb-spark-2 { width: 12rpx; height: 12rpx; background: #fb923c; }
|
||||
.ppb-spark-2.ppb-spark--active { animation-name: ppbSpark2; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark2 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) scale(0.0); }
|
||||
12% { opacity: 1; transform: translate( 10rpx, 14rpx) scale(1.5); }
|
||||
28% { opacity: 0.8; transform: translate( 19rpx, 24rpx) scale(1.1); }
|
||||
46% { opacity: 0.6; transform: translate( 26rpx, 32rpx) scale(0.8); }
|
||||
62% { opacity: 0.4; transform: translate( 31rpx, 38rpx) scale(0.6); }
|
||||
76% { opacity: 0.25; transform: translate( 35rpx, 43rpx) scale(0.4); }
|
||||
88% { opacity: 0.1; transform: translate( 38rpx, 47rpx) scale(0.25); }
|
||||
100% { opacity: 0; transform: translate( 42rpx, 56rpx) scale(0.1); }
|
||||
}
|
||||
|
||||
/* 粒子3:正上,黄色 */
|
||||
.ppb-spark-3 { width: 8rpx; height: 8rpx; background: #fde68a; }
|
||||
.ppb-spark-3.ppb-spark--active { animation-name: ppbSpark3; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark3 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) scale(0.0); }
|
||||
6% { opacity: 1; transform: translate( 4rpx, -22rpx) scale(1.8); }
|
||||
20% { opacity: 0.9; transform: translate( 7rpx, -36rpx) scale(1.3); }
|
||||
38% { opacity: 0.7; transform: translate( 9rpx, -48rpx) scale(1.0); }
|
||||
55% { opacity: 0.5; transform: translate( 10rpx, -57rpx) scale(0.7); }
|
||||
70% { opacity: 0.3; transform: translate( 11rpx, -63rpx) scale(0.5); }
|
||||
84% { opacity: 0.15; transform: translate( 11rpx, -68rpx) scale(0.3); }
|
||||
100% { opacity: 0; transform: translate( 12rpx, -74rpx) scale(0.1); }
|
||||
}
|
||||
|
||||
/* 粒子4:右斜上,红橙,拖尾 */
|
||||
.ppb-spark-4 { width: 16rpx; height: 6rpx; background: #ef4444; }
|
||||
.ppb-spark-4.ppb-spark--active { animation-name: ppbSpark4; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark4 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) rotate( 0deg) scale(0.0); }
|
||||
10% { opacity: 1; transform: translate( 14rpx, -10rpx) rotate(-20deg) scale(1.4); }
|
||||
26% { opacity: 0.8; transform: translate( 24rpx, -16rpx) rotate(-30deg) scale(1.0); }
|
||||
44% { opacity: 0.6; transform: translate( 32rpx, -21rpx) rotate(-38deg) scale(0.75); }
|
||||
60% { opacity: 0.4; transform: translate( 39rpx, -26rpx) rotate(-45deg) scale(0.55); }
|
||||
74% { opacity: 0.25; transform: translate( 44rpx, -30rpx) rotate(-50deg) scale(0.4); }
|
||||
87% { opacity: 0.1; transform: translate( 48rpx, -33rpx) rotate(-53deg) scale(0.25); }
|
||||
100% { opacity: 0; transform: translate( 58rpx, -40rpx) rotate(-55deg) scale(0.1); }
|
||||
}
|
||||
|
||||
/* 粒子5:正右,黄白 */
|
||||
.ppb-spark-5 { width: 10rpx; height: 10rpx; background: #fbbf24; }
|
||||
.ppb-spark-5.ppb-spark--active { animation-name: ppbSpark5; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark5 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) scale(0.0); }
|
||||
5% { opacity: 1; transform: translate( 18rpx, 2rpx) scale(1.7); }
|
||||
20% { opacity: 0.85; transform: translate( 30rpx, 3rpx) scale(1.2); }
|
||||
38% { opacity: 0.65; transform: translate( 40rpx, 4rpx) scale(0.9); }
|
||||
55% { opacity: 0.45; transform: translate( 47rpx, 5rpx) scale(0.7); }
|
||||
70% { opacity: 0.3; transform: translate( 52rpx, 6rpx) scale(0.5); }
|
||||
84% { opacity: 0.15; transform: translate( 56rpx, 7rpx) scale(0.3); }
|
||||
100% { opacity: 0; transform: translate( 60rpx, 8rpx) scale(0.1); }
|
||||
}
|
||||
|
||||
/* 粒子6:右下斜,淡橙 */
|
||||
.ppb-spark-6 { width: 14rpx; height: 14rpx; background: #fed7aa; }
|
||||
.ppb-spark-6.ppb-spark--active { animation-name: ppbSpark6; animation-timing-function: linear; }
|
||||
@keyframes ppbSpark6 {
|
||||
0% { opacity: 0; transform: translate( 0rpx, 0rpx) scale(0.0); }
|
||||
15% { opacity: 0.9; transform: translate( 6rpx, 18rpx) scale(1.5); }
|
||||
30% { opacity: 0.75; transform: translate( 10rpx, 28rpx) scale(1.1); }
|
||||
47% { opacity: 0.55; transform: translate( 14rpx, 36rpx) scale(0.85); }
|
||||
62% { opacity: 0.4; transform: translate( 17rpx, 43rpx) scale(0.65); }
|
||||
75% { opacity: 0.25; transform: translate( 19rpx, 48rpx) scale(0.5); }
|
||||
87% { opacity: 0.1; transform: translate( 21rpx, 53rpx) scale(0.35); }
|
||||
100% { opacity: 0; transform: translate( 24rpx, 64rpx) scale(0.1); }
|
||||
}
|
||||
|
||||
/* ── 刻度 ── */
|
||||
.ppb-ticks {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 100%;
|
||||
margin-top: 6rpx;
|
||||
height: 24rpx;
|
||||
}
|
||||
.ppb-tick {
|
||||
position: absolute;
|
||||
font-size: 16rpx;
|
||||
color: var(--ppb-tick-color, rgba(255, 255, 255, 0.55));
|
||||
transition: color 0.3s ease, font-weight 0.3s ease;
|
||||
}
|
||||
.ppb-tick--done {
|
||||
color: var(--ppb-tick-done-color, rgba(255, 255, 255, 0.95));
|
||||
font-weight: 600;
|
||||
}
|
||||
.ppb-tick--highlight {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* service-record-card — 服务记录单条卡片组件
|
||||
* 用于 task-detail「60天内服务记录」和 customer-service-records 列表
|
||||
*
|
||||
* 数据约定(两端统一传原始数字,组件负责格式化):
|
||||
* hours / hoursRaw — 小时数(number),组件展示时加 h 后缀
|
||||
* income — 金额(number),组件展示时加 ¥ 前缀
|
||||
*/
|
||||
Component({
|
||||
properties: {
|
||||
/** 时间,如 "2026-02-07 21:30" */
|
||||
time: { type: String, value: '' },
|
||||
/** 课程标签文字,如 "基础课" "包厢课" "打赏课" */
|
||||
courseLabel: { type: String, value: '' },
|
||||
/** 课程样式 class 后缀:basic / vip / tip / recharge */
|
||||
typeClass: { type: String, value: 'basic' },
|
||||
/** 卡片类型:默认 course;recharge=充值提成 */
|
||||
type: { type: String, value: 'course' },
|
||||
/** 台桌号,如 "A12号台" */
|
||||
tableNo: { type: String, value: '' },
|
||||
/** 折算后小时数(原始数字,如 2.5),组件展示时加 h 后缀 */
|
||||
hours: { type: Number, value: 0 },
|
||||
/** 折算前小时数(原始数字,如 3.0),组件展示时加 h 后缀 */
|
||||
hoursRaw: { type: Number, value: 0 },
|
||||
/** 商品/饮品描述 */
|
||||
drinks: { type: String, value: '' },
|
||||
/** 金额(原始数字,如 200),组件展示时加 ¥ 前缀 */
|
||||
income: { type: Number, value: 0 },
|
||||
/** 是否为预估金额(显示「预估」标签) */
|
||||
isEstimate: { type: Boolean, value: false },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
<!-- service-record-card 组件 —— 60天内服务记录单条卡片 -->
|
||||
<view class="svc-card {{type === 'recharge' ? 'svc-card--recharge' : ''}}" hover-class="svc-card--hover">
|
||||
<!-- 第一行:左=时间,右=课程+台桌+小时数 -->
|
||||
<view class="svc-row svc-row1">
|
||||
<text class="svc-time">{{time}}</text>
|
||||
<view class="svc-right1">
|
||||
<text class="svc-course-tag svc-course-{{typeClass}}">{{type === 'recharge' ? '充值' : courseLabel}}</text>
|
||||
<text class="svc-table-label" wx:if="{{tableNo && type !== 'recharge'}}">{{tableNo}}</text>
|
||||
<text class="svc-hours" wx:if="{{hours && type !== 'recharge'}}">{{hours}}h</text>
|
||||
<text class="svc-hours-raw" wx:if="{{hoursRaw && hoursRaw !== hours && type !== 'recharge'}}">折前{{hoursRaw}}h</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 第二行:左=商品,右=金额(含预估/提成) -->
|
||||
<view class="svc-row svc-row2">
|
||||
<text class="svc-drinks">{{drinks || '—'}}</text>
|
||||
<view class="svc-income-wrap">
|
||||
<text class="svc-income-est" wx:if="{{isEstimate && type !== 'recharge'}}">预估</text>
|
||||
<text class="svc-income-label">{{type === 'recharge' ? '提成' : '到手'}}</text>
|
||||
<text class="svc-income {{type === 'recharge' ? 'svc-income--recharge' : ''}}">¥{{income}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,136 @@
|
||||
/* service-record-card 组件样式 */
|
||||
|
||||
.svc-card {
|
||||
padding: 22rpx 26rpx;
|
||||
border-radius: 22rpx;
|
||||
border: 2rpx solid #eeeeee;
|
||||
background: linear-gradient(135deg, #fafafa 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.svc-card--hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.svc-card--recharge {
|
||||
background: linear-gradient(135deg, #fffef0 0%, #fffbeb 100%);
|
||||
border-color: #fef3c7;
|
||||
}
|
||||
|
||||
/* 行通用 */
|
||||
.svc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.svc-row1 {
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
/* 第一行左:时间 */
|
||||
.svc-time {
|
||||
font-size: 24rpx;
|
||||
line-height: 32rpx;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
/* 第一行右:课程+台桌+小时 */
|
||||
.svc-right1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.svc-course-tag {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx;
|
||||
font-weight: 500;
|
||||
padding: 3rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.svc-course-basic {
|
||||
background: rgba(0, 168, 112, 0.10);
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
.svc-course-vip {
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.svc-course-tip {
|
||||
background: rgba(237, 123, 47, 0.10);
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.svc-course-recharge {
|
||||
background: rgba(212, 160, 23, 0.12);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.svc-table-label {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx;
|
||||
color: #5a5a5a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.svc-hours {
|
||||
font-size: 28rpx;
|
||||
line-height: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #393939;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.svc-hours-raw {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx;
|
||||
color: #b9b9b9;
|
||||
}
|
||||
|
||||
/* 第二行左:商品 */
|
||||
.svc-drinks {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx;
|
||||
color: #8b8b8b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 340rpx;
|
||||
}
|
||||
|
||||
/* 第二行右:金额 */
|
||||
.svc-income-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.svc-income-label {
|
||||
font-size: 20rpx;
|
||||
line-height: 26rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.svc-income {
|
||||
font-size: 30rpx;
|
||||
line-height: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.svc-income--recharge {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.svc-income-est {
|
||||
font-size: 20rpx;
|
||||
line-height: 26rpx;
|
||||
color: #ed7b2f;
|
||||
font-weight: 500;
|
||||
margin-right: -14rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-rate": "tdesign-miniprogram/rate/rate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
Component({
|
||||
properties: {
|
||||
/** 评分 0-10,内部转换为 0-5 星,支持半星 */
|
||||
score: {
|
||||
type: Number,
|
||||
value: 0,
|
||||
},
|
||||
/** 星星尺寸 */
|
||||
size: {
|
||||
type: String,
|
||||
value: '40rpx',
|
||||
},
|
||||
/** 是否只读 */
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
|
||||
observers: {
|
||||
score(val: number) {
|
||||
// score(0-10) → star(0-5),支持半星(如 7.5 → 3.75星)
|
||||
const clamped = Math.max(0, Math.min(10, val))
|
||||
this.setData({ starValue: clamped / 2 })
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
starValue: 0,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const clamped = Math.max(0, Math.min(10, this.data.score))
|
||||
this.setData({ starValue: clamped / 2 })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
<t-rate
|
||||
value="{{starValue}}"
|
||||
count="{{5}}"
|
||||
allow-half
|
||||
size="{{size}}"
|
||||
color="#fbbf24"
|
||||
gap="{{4}}"
|
||||
disabled="{{readonly}}"
|
||||
/>
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 星星评分组件样式 */
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
Reference in New Issue
Block a user