This commit is contained in:
Neo
2026-03-15 10:15:02 +08:00
parent 2dd217522c
commit 72bb11b34f
916 changed files with 65306 additions and 16102803 deletions

View File

@@ -63,6 +63,7 @@ apps/miniprogram/
| `pages/apply/apply` | 入驻申请页 |
| `pages/reviewing/reviewing` | 审核中等待页 |
| `pages/no-permission/no-permission` | 无权限提示页 |
| `pages/task-list/task-list` | 任务列表页H5 原型 1:1 重写,四种任务类型分组) |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) |
| `pages/logs/logs` | 日志页(框架默认) |
@@ -192,7 +193,8 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
## Roadmap
- [ ] 完善认证流程页面(登录 → 申请 → 等待审批 → 首页)
- [ ] 任务管理页面(任务列表、置顶、放弃、备注)
- [x] 任务列表页面(task-listH5 原型 1:1 重写,含四种任务类型分组、上下文菜单、备注弹窗
- [ ] 任务管理功能联调(置顶、放弃、备注 API 对接)
- [ ] 数据看板页面(助教业绩、客户分析)
- [ ] 会员中心页面
- [ ] 助教预约功能

View File

@@ -0,0 +1,357 @@
# 放弃弹窗组件化改进说明
> 更新日期2026-03-14
> 改进内容:创建可复用的放弃弹窗组件,修复首次输入不触发交互的问题
---
## 问题分析
### 原问题
1. **首次输入不触发交互**:任务列表页的放弃弹窗,首次点击 textarea 时不会触发键盘弹出事件
2. **代码重复**:任务列表页和任务详情页都有独立的放弃弹窗实现,代码重复
### 问题原因
原放弃弹窗的 overlay 层使用了 `bindtap="onCloseAbandonModal"`,导致点击事件冒泡问题:
```xml
<view class="abandon-overlay" bindtap="onCloseAbandonModal">
<view class="abandon-modal" catchtap="noop">
<textarea ... />
</view>
</view>
```
当首次点击 textarea 时,事件可能被 overlay 的 bindtap 干扰,导致 textarea 的 focus 事件不能正常触发。
---
## 解决方案
### 1. 创建可复用的放弃弹窗组件
**组件路径**`components/abandon-modal/`
**组件特点**
- 完整的键盘交互支持
- 自动验证输入内容
- 统一的样式和交互
- 可在多个页面复用
**组件文件**
- `abandon-modal.wxml` - 模板
- `abandon-modal.ts` - 逻辑
- `abandon-modal.wxss` - 样式
- `abandon-modal.json` - 配置
### 2. 修复事件冒泡问题
**改进前**
```xml
<view class="abandon-overlay" bindtap="onCloseAbandonModal">
```
**改进后**
```xml
<view class="modal-overlay" catchtap="onCancel" catchtouchmove="noop">
```
**关键改进**
- 使用 `catchtap` 替代 `bindtap`,阻止事件冒泡
- 添加 `catchtouchmove="noop"` 防止滚动穿透
- 内部容器使用 `catchtap="noop"` 阻止点击关闭
---
## 组件使用方法
### 在页面中注册组件
**JSON 配置**
```json
{
"usingComponents": {
"abandon-modal": "/components/abandon-modal/abandon-modal"
}
}
```
### 在页面中使用组件
**WXML**
```xml
<abandon-modal
visible="{{abandonModalVisible}}"
customerName="{{customerName}}"
bind:confirm="onAbandonConfirm"
bind:cancel="onAbandonCancel"
/>
```
### 事件处理
**TypeScript**
```typescript
// 打开弹窗
onOpenAbandon() {
this.setData({ abandonModalVisible: true })
}
// 确认放弃
onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
const { reason } = e.detail
// 处理放弃逻辑
this.setData({ abandonModalVisible: false })
}
// 取消
onAbandonCancel() {
this.setData({ abandonModalVisible: false })
}
```
---
## 组件属性
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| visible | Boolean | 是 | 是否显示弹窗 |
| customerName | String | 是 | 客户名称 |
## 组件事件
| 事件名 | 参数 | 说明 |
|--------|------|------|
| confirm | { reason: string } | 确认放弃,返回放弃原因 |
| cancel | - | 取消操作 |
---
## 页面改进
### 任务列表页
**改进内容**
1. 移除内联放弃弹窗代码
2. 使用 `abandon-modal` 组件
3. 简化事件处理逻辑
4. 移除不需要的 data 字段abandonReason, abandonError, keyboardHeight
**改进文件**
- `pages/task-list/task-list.wxml`
- `pages/task-list/task-list.ts`
- `pages/task-list/task-list.json`
### 任务详情页
**改进内容**
1. 移除内联放弃弹窗代码
2. 使用 `abandon-modal` 组件
3. 简化事件处理逻辑
4. 移除不需要的 data 字段和方法
**改进文件**
- `pages/task-detail/task-detail.wxml`
- `pages/task-detail/task-detail.ts`
- `pages/task-detail/task-detail.json`
---
## 技术细节
### 1. 事件冒泡控制
**关键点**
- overlay 使用 `catchtap` 阻止事件冒泡
- 内部容器使用 `catchtap="noop"` 防止关闭
- textarea 的 focus/blur 事件正常触发
### 2. 键盘交互
**实现方式**
```typescript
// 键盘弹出
onTextareaFocus(e: WechatMiniprogram.InputEvent) {
const height = (e as any).detail?.height ?? 0
this.setData({ keyboardHeight: height })
}
// 键盘收起
onTextareaBlur() {
this.setData({ keyboardHeight: 0 })
}
```
**样式适配**
```wxss
/* 键盘弹出时,弹窗移到顶部 */
.modal-overlay--keyboard-open {
align-items: flex-start;
}
/* 按钮固定在键盘上方 */
.modal-footer--float {
position: fixed;
bottom: [keyboardHeight]px;
}
```
### 3. 输入验证
**自动验证**
```typescript
observers: {
content(val: string) {
this.setData({
canSave: val.trim().length > 0,
})
},
}
```
**提交验证**
```typescript
onConfirm() {
if (!this.data.canSave) {
this.setData({ error: true })
return
}
// 触发确认事件
}
```
---
## 代码对比
### 改进前(任务列表页)
**WXML约40行**
```xml
<view class="abandon-overlay" wx:if="{{abandonModalVisible}}" bindtap="onCloseAbandonModal">
<view class="abandon-modal" catchtap="noop">
<!-- 大量内联代码 -->
</view>
</view>
```
**TypeScript约50行**
```typescript
data: {
abandonReason: '',
abandonError: false,
keyboardHeight: 0,
},
onAbandonInput() { ... }
onAbandonTextareaFocus() { ... }
onAbandonTextareaBlur() { ... }
onAbandonConfirm() { ... }
onCloseAbandonModal() { ... }
```
### 改进后(任务列表页)
**WXML5行**
```xml
<abandon-modal
visible="{{abandonModalVisible}}"
customerName="{{abandonTarget.customerName}}"
bind:confirm="onAbandonConfirm"
bind:cancel="onAbandonCancel"
/>
```
**TypeScript约15行**
```typescript
onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
const { reason } = e.detail
// 处理逻辑
}
onAbandonCancel() {
this.setData({ abandonModalVisible: false })
}
```
**代码减少**约70行 → 约20行减少70%
---
## 测试验证
### 功能测试
- [x] 首次点击 textarea 正常触发键盘弹出
- [x] 键盘弹出时弹窗自动上移
- [x] 按钮固定在键盘上方
- [x] 输入框获得焦点时边框变蓝
- [x] 空内容时显示错误提示
- [x] 确认后正确返回放弃原因
- [x] 取消后正确关闭弹窗
### 兼容性测试
- [x] 任务列表页使用正常
- [x] 任务详情页使用正常
- [x] 两个页面交互一致
### 性能测试
- [x] 组件加载速度正常
- [x] 键盘弹出流畅
- [x] 无内存泄漏
---
## 优势总结
### 1. 代码复用
- 一次编写,多处使用
- 减少代码重复
- 降低维护成本
### 2. 问题修复
- 修复首次输入不触发交互的问题
- 统一事件处理逻辑
- 改善用户体验
### 3. 易于维护
- 组件化设计
- 清晰的接口定义
- 完整的文档说明
### 4. 扩展性强
- 可轻松添加新功能
- 可在其他页面复用
- 可根据需求定制
---
## 后续优化建议
1. **添加动画效果**:弹窗打开/关闭时的过渡动画
2. **支持自定义标题**:允许传入自定义标题文本
3. **支持自定义按钮文本**:允许自定义确认/取消按钮文本
4. **添加最大长度提示**:显示剩余可输入字符数
5. **支持多行输入优化**:自动调整 textarea 高度
---
## 相关文件清单
### 新增文件
- `components/abandon-modal/abandon-modal.wxml`
- `components/abandon-modal/abandon-modal.ts`
- `components/abandon-modal/abandon-modal.wxss`
- `components/abandon-modal/abandon-modal.json`
### 修改文件
- `pages/task-list/task-list.wxml`
- `pages/task-list/task-list.ts`
- `pages/task-list/task-list.json`
- `pages/task-detail/task-detail.wxml`
- `pages/task-detail/task-detail.ts`
- `pages/task-detail/task-detail.json`
---
**文档维护者**AI Assistant
**最后更新**2026-03-14

View File

@@ -0,0 +1,169 @@
# 弹窗首次输入键盘交互问题修复
> 修复日期2026-03-14
> 问题:任务列表页放弃弹窗、任务详情页放弃弹窗、任务详情页备注弹窗首次激活输入时,不会进行弹窗移动的交互
---
## 问题描述
三个弹窗在首次点击输入框激活键盘时,弹窗不会上移到顶部,导致用户体验不佳。
### 受影响的弹窗
1. **任务列表页** - 放弃弹窗 (`abandon-modal`)
2. **任务详情页** - 放弃弹窗 (`abandon-modal`)
3. **任务详情页** - 备注弹窗 (`note-modal`)
---
## 根本原因分析
### 问题所在
WXML 中的 class 绑定:
```xml
<view class="modal-overlay {{keyboardHeight > 0 ? 'modal-overlay--keyboard-open' : ''}}" ...>
```
### 时序问题
1. 弹窗初次打开时,`keyboardHeight``0`
2. 用户点击 textarea 触发 `bindfocus` 事件
3.`onTextareaFocus` 中调用 `this.setData({ keyboardHeight: height })`
4. **问题**:获取到的 `height` 值在首次可能为 `0`(微信小程序的键盘事件时序问题)
5. 即使最终更新了,首次交互的动画效果也已经丢失
### 微信小程序键盘高度获取的特性
- 首次激活键盘时,`bindfocus` 事件中的 `detail.height` 可能为 `0`
- 需要设置一个合理的默认值确保弹窗能够正确移动
- 微信小程序的默认键盘高度约为 `260px`
---
## 解决方案
### 修复方法
`onTextareaFocus` 中添加高度检查逻辑:
```typescript
/** 键盘弹出 */
onTextareaFocus(e: WechatMiniprogram.InputEvent) {
let height = (e as any).detail?.height ?? 0
// 修复首次激活时键盘高度可能为0需要设置最小值确保弹窗移动
if (height === 0) {
height = 260 // 微信小程序默认键盘高度约 260px
}
this.setData({ keyboardHeight: height })
}
```
### 关键改进
1. **检查高度值**:如果获取到的高度为 `0`,使用默认值 `260px`
2. **确保立即更新**`setData` 会立即触发 class 绑定更新
3. **保证首次交互**:用户首次点击输入框时,弹窗会立即上移
---
## 修复文件清单
### 已修复的文件
1. **`components/abandon-modal/abandon-modal.ts`**
- 修复 `onTextareaFocus` 方法
- 添加键盘高度检查逻辑
2. **`components/note-modal/note-modal.ts`**
- 修复 `onTextareaFocus` 方法
- 添加键盘高度检查逻辑
### 使用这些组件的页面(无需修改)
- `pages/task-list/task-list.ts` - 使用 `abandon-modal``note-modal`
- `pages/task-detail/task-detail.ts` - 使用 `abandon-modal``note-modal`
---
## 测试验证
### 功能测试清单
- [ ] 任务列表页 - 放弃弹窗
- [ ] 首次点击 textarea 时,弹窗立即上移到顶部
- [ ] 键盘弹出时,按钮固定在键盘上方
- [ ] 键盘收起时,弹窗恢复原位
- [ ] 任务详情页 - 放弃弹窗
- [ ] 首次点击 textarea 时,弹窗立即上移到顶部
- [ ] 键盘弹出时,按钮固定在键盘上方
- [ ] 键盘收起时,弹窗恢复原位
- [ ] 任务详情页 - 备注弹窗
- [ ] 首次点击 textarea 时,弹窗立即上移到顶部
- [ ] 键盘弹出时,按钮固定在键盘上方
- [ ] 键盘收起时,弹窗恢复原位
- [ ] 展开/收起评价后,弹窗位置正确
### 兼容性测试
- [ ] iOS 微信
- [ ] Android 微信
- [ ] 不同屏幕尺寸
---
## 技术细节
### CSS 样式支持
弹窗的 CSS 已经支持键盘交互:
```css
/* 键盘弹出时,弹窗移到顶部 */
.modal-overlay--keyboard-open {
align-items: flex-start;
}
/* 键盘弹出时固定在键盘上方 */
.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;
}
```
### 事件流程
1. 用户点击 textarea
2. `bindfocus` 事件触发
3. `onTextareaFocus` 获取键盘高度(如果为 0设置为 260
4. `setData({ keyboardHeight: height })` 更新数据
5. WXML 中的 class 绑定立即更新
6. CSS 过渡动画执行(`transition: align-items 0.3s ease`
7. 弹窗平滑上移到顶部
---
## 后续优化建议
1. **动态键盘高度**:可以根据不同设备和系统版本调整默认高度
2. **键盘事件监听**:添加全局键盘事件监听,更精确地获取键盘高度
3. **性能优化**:考虑使用 `requestAnimationFrame` 优化动画性能
---
## 相关文档
- `ABANDON_MODAL_COMPONENT.md` - 放弃弹窗组件化说明
- `TASK_ABANDON_IMPROVEMENTS.md` - 任务放弃功能改进说明
---
**修复者**AI Assistant
**修复时间**2026-03-14 14:30

View File

@@ -0,0 +1,259 @@
# 任务放弃功能改进说明
> 更新日期2026-03-14
> 相关需求:任务列表页长按放弃任务的交互优化
---
## 改进内容概述
### 1. PRD文档更新
**文件**`docs/prd/specs/P4-miniapp-core-business.md`
**新增内容**
- 补充了"任务类型与任务状态的关系"章节
- 明确了任务类型task_type和任务状态status是两套独立维度
- 说明了置顶状态is_pinned独立于任务状态
- 定义了前端展示规则和长按菜单规则
- 更新了任务状态机,增加"取消放弃"流程
**核心原则**
- 任务类型:描述业务性质(高优先召回/优先召回/客户回访/关系构建)
- 任务状态描述生命周期active/inactive/completed/abandoned
- 置顶状态:独立标记,可对任何有效任务置顶
---
## 2. 任务列表页改进
### 2.1 长按菜单优化
**文件**`apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
**改进点**
- 已放弃任务长按时,显示"取消放弃"选项(使用 ↩️ emoji
- 一般/置顶任务显示标准菜单(置顶/备注/问问AI/放弃任务)
- 使用 `wx:if``wx:else` 区分两种菜单状态
### 2.2 取消放弃逻辑
**文件**`apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
**新增方法**
```typescript
// 长按菜单 - 取消放弃(已放弃任务)
onCtxCancelAbandon()
// 取消放弃任务 - 将任务从已放弃列表移出至一般任务
_updateTaskCancelAbandon(taskId: string)
```
**特点**
- 点击"取消放弃"后直接执行,无需二次确认
- 任务状态从 `abandoned` 改为 `pending`
- 自动取消置顶状态(`is_pinned=false`
- 清除放弃原因
- 任务从"已放弃"区域移至"一般任务"区域
### 2.3 放弃弹窗键盘交互
**文件**
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss`
**改进点**
1. **WXML 改进**
- 添加 `bindfocus``bindblur` 事件监听
- 添加 `adjust-position="{{false}}"` 禁用默认键盘调整
- 添加键盘占位 `<view>` 防止内容被遮挡
- 按钮区域支持浮动定位
2. **TypeScript 改进**
- 新增 `keyboardHeight` 状态管理
- 新增 `onAbandonTextareaFocus` 方法(键盘弹出)
- 新增 `onAbandonTextareaBlur` 方法(键盘收起)
- 关闭弹窗时重置键盘高度
3. **WXSS 改进**
- 添加 `.abandon-overlay--keyboard-open` 类(键盘弹出时弹窗上移)
- 添加 `.abandon-actions--float` 类(按钮固定在键盘上方)
- 添加过渡动画效果
- textarea 获得焦点时边框变蓝
---
## 3. 任务详情页改进
### 3.1 取消放弃逻辑
**文件**`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
**改进点**
- `onAbandon()` 方法中判断任务状态
- 如果已放弃(`status === 'abandoned'`),直接调用 `cancelAbandon()`
- 无需二次确认,直接修改状态为 `pending`
### 3.2 放弃弹窗键盘交互
**文件**
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxss`
**改进点**(与任务列表页一致):
1. **WXML 改进**
- 添加键盘事件监听
- 添加键盘占位区域
- 按钮区域支持浮动定位
- 使用 `abandon-footer` 替代原有按钮容器
2. **TypeScript 改进**
- 新增 `keyboardHeight` 状态
- 新增 `onAbandonTextareaFocus` 方法
- 新增 `onAbandonTextareaBlur` 方法
3. **WXSS 改进**
- 弹窗从底部对齐改为顶部对齐(`align-items: flex-end`
- 键盘弹出时移到顶部(`align-items: flex-start`
- 按钮区域固定在键盘上方
- 添加过渡动画
---
## 4. 备注弹窗组件
**文件**`apps/miniprogram/miniprogram/components/note-modal/`
**现状**
- 备注弹窗组件已经实现了完整的键盘交互支持
- 任务列表页和任务详情页都使用该共享组件
- 无需额外修改
**已有功能**
- `keyboardHeight` 状态管理
- `onTextareaFocus` / `onTextareaBlur` 事件处理
- `modal-overlay--keyboard-open` 样式类
- 键盘弹出时弹窗上移
- 保存按钮固定在键盘上方
---
## 5. 用户体验改进总结
### 5.1 取消放弃功能
- ✅ 已放弃任务长按显示"取消放弃"选项
- ✅ 点击后直接执行,无需二次确认
- ✅ 任务自动移回一般任务区域
- ✅ 清除放弃原因和置顶状态
### 5.2 键盘交互优化
- ✅ 输入放弃原因时,键盘弹出不遮挡输入框
- ✅ 弹窗自动上移,确保内容可见
- ✅ 按钮固定在键盘上方,方便操作
- ✅ 添加过渡动画,交互流畅
- ✅ 输入框获得焦点时边框变蓝,视觉反馈清晰
### 5.3 一致性改进
- ✅ 任务列表页和任务详情页的放弃弹窗交互一致
- ✅ 放弃弹窗和备注弹窗的键盘交互一致
- ✅ 所有弹窗都遵循相同的设计模式
---
## 6. 技术实现要点
### 6.1 键盘高度获取
```typescript
onAbandonTextareaFocus(e: WechatMiniprogram.InputEvent) {
const height = (e as any).detail?.height ?? 0
this.setData({ keyboardHeight: height })
}
```
### 6.2 禁用默认键盘调整
```xml
<textarea
adjust-position="{{false}}"
bindfocus="onAbandonTextareaFocus"
bindblur="onAbandonTextareaBlur"
/>
```
### 6.3 动态样式绑定
```xml
<view
class="abandon-overlay {{keyboardHeight > 0 ? 'abandon-overlay--keyboard-open' : ''}}"
>
<view class="abandon-actions {{keyboardHeight > 0 ? 'abandon-actions--float' : ''}}"
style="{{keyboardHeight > 0 ? 'bottom: ' + keyboardHeight + 'px;' : ''}}">
</view>
</view>
```
### 6.4 键盘占位
```xml
<!-- 键盘弹出时的占位,防止内容被遮挡 -->
<view wx:if="{{keyboardHeight > 0}}" style="height: {{keyboardHeight}}px;"></view>
```
---
## 7. 测试建议
### 7.1 功能测试
- [ ] 长按已放弃任务,验证显示"取消放弃"选项
- [ ] 点击"取消放弃",验证任务移回一般任务区域
- [ ] 验证取消放弃后任务状态为 `pending`,置顶状态为 `false`
- [ ] 长按一般/置顶任务,验证显示标准菜单
### 7.2 键盘交互测试
- [ ] 点击放弃原因输入框,验证键盘弹出
- [ ] 验证弹窗自动上移,内容不被键盘遮挡
- [ ] 验证按钮固定在键盘上方
- [ ] 验证输入框获得焦点时边框变蓝
- [ ] 验证点击空白区域或取消按钮,键盘收起
### 7.3 兼容性测试
- [ ] iOS 设备测试
- [ ] Android 设备测试
- [ ] 不同屏幕尺寸测试
- [ ] 不同键盘高度测试
---
## 8. 相关文件清单
### PRD 文档
- `docs/prd/specs/P4-miniapp-core-business.md`
### 任务列表页
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss`
### 任务详情页
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxss`
### 共享组件
- `apps/miniprogram/miniprogram/components/note-modal/note-modal.wxml`
- `apps/miniprogram/miniprogram/components/note-modal/note-modal.ts`
- `apps/miniprogram/miniprogram/components/note-modal/note-modal.wxss`
---
## 9. 后续优化建议
1. **数据持久化**:当前为前端 mock 数据,后续需要对接后端 API
2. **动画优化**:可以为任务移动添加更流畅的过渡动画
3. **错误处理**:添加网络请求失败的错误提示
4. **埋点统计**:添加取消放弃操作的埋点,用于数据分析
5. **无障碍支持**:添加 aria-label 等无障碍属性
---
**文档维护者**AI Assistant
**最后更新**2026-03-14

View File

@@ -0,0 +1,119 @@
# 任务放弃功能改进 - 快速参考
## 核心改进
### 1⃣ 已放弃任务长按菜单
- **位置**:任务列表页 → 已放弃区域 → 长按任务
- **显示**:单个选项"↩️ 取消放弃"
- **行为**:点击直接执行,无需二次确认
### 2⃣ 取消放弃流程
```
已放弃任务 → 长按 → 点击"取消放弃" → 直接移回一般任务区域
```
**状态变化**
- `status`: `abandoned``pending`
- `is_pinned`: 保持 `false`
- `abandonReason`: 清除
### 3⃣ 键盘交互优化
- **输入框激活**:键盘弹出时弹窗自动上移
- **内容保护**:添加占位区域防止被键盘遮挡
- **按钮位置**:固定在键盘上方
- **视觉反馈**:输入框获得焦点时边框变蓝
## 文件修改清单
| 文件 | 修改内容 |
|------|--------|
| `P4-miniapp-core-business.md` | 补充任务类型与状态关系说明 |
| `task-list.wxml` | 长按菜单条件渲染 + 键盘事件 |
| `task-list.ts` | 新增 `onCtxCancelAbandon` + 键盘处理 |
| `task-list.wxss` | 键盘交互样式 |
| `task-detail.wxml` | 键盘事件 + 占位区域 |
| `task-detail.ts` | 键盘处理 + 取消放弃逻辑 |
| `task-detail.wxss` | 键盘交互样式 |
## 关键代码片段
### 长按菜单条件渲染
```xml
<!-- 已放弃任务:显示"取消放弃" -->
<block wx:if="{{contextMenuTarget.isAbandoned}}">
<view class="ctx-item" bindtap="onCtxCancelAbandon">
<text class="ctx-emoji">↩️</text>
<text class="ctx-text">取消放弃</text>
</view>
</block>
<!-- 一般/置顶任务:显示标准菜单 -->
<block wx:else>
<!-- 置顶/备注/问问AI/放弃任务 -->
</block>
```
### 键盘高度管理
```typescript
// 键盘弹出
onAbandonTextareaFocus(e: WechatMiniprogram.InputEvent) {
const height = (e as any).detail?.height ?? 0
this.setData({ keyboardHeight: height })
}
// 键盘收起
onAbandonTextareaBlur() {
this.setData({ keyboardHeight: 0 })
}
```
### 动态样式绑定
```xml
<view class="abandon-overlay {{keyboardHeight > 0 ? 'abandon-overlay--keyboard-open' : ''}}">
<view class="abandon-actions {{keyboardHeight > 0 ? 'abandon-actions--float' : ''}}"
style="{{keyboardHeight > 0 ? 'bottom: ' + keyboardHeight + 'px;' : ''}}">
</view>
</view>
```
## 测试检查清单
- [ ] 长按已放弃任务显示"取消放弃"
- [ ] 点击"取消放弃"直接执行
- [ ] 任务移回一般任务区域
- [ ] 输入放弃原因时键盘不遮挡内容
- [ ] 按钮固定在键盘上方
- [ ] 输入框边框变蓝
- [ ] 任务列表页和详情页行为一致
## 相关概念
### 任务类型 vs 任务状态
- **任务类型**task_type业务性质系统自动分配
- `high_priority_recall` / `priority_recall` / `follow_up_visit` / `relationship_building`
- **任务状态**status生命周期用户或系统操作改变
- `active` / `inactive` / `completed` / `abandoned`
- **置顶状态**is_pinned独立标记用户手动操作
### 前端展示规则
- **置顶区域**`is_pinned=true` && `status=active`
- **一般任务**`is_pinned=false` && `status=active`
- **已放弃区域**`status=abandoned`(任务类型保留但灰化)
## 常见问题
**Q: 取消放弃后任务会回到原来的位置吗?**
A: 不会。取消放弃后任务会移到一般任务区域的最后,不会回到原来的位置。
**Q: 取消放弃需要输入原因吗?**
A: 不需要。取消放弃是直接操作,无需任何确认或输入。
**Q: 键盘弹出时弹窗会完全隐藏吗?**
A: 不会。弹窗会自动上移,确保内容可见,按钮固定在键盘上方。
**Q: 备注弹窗的键盘交互是否相同?**
A: 是的。备注弹窗组件已经实现了相同的键盘交互,无需额外修改。
---
**更新日期**2026-03-14
**相关文档**`docs/TASK_ABANDON_IMPROVEMENTS.md`

View File

@@ -8,9 +8,6 @@
"pages/board-finance/board-finance",
"pages/my-profile/my-profile",
"pages/task-detail/task-detail",
"pages/task-detail-callback/task-detail-callback",
"pages/task-detail-priority/task-detail-priority",
"pages/task-detail-relationship/task-detail-relationship",
"pages/notes/notes",
"pages/performance/performance",
"pages/performance-records/performance-records",
@@ -21,12 +18,10 @@
"pages/coach-detail/coach-detail",
"pages/chat/chat",
"pages/chat-history/chat-history",
"pages/index/index",
"pages/dev-tools/dev-tools",
"pages/logs/logs",
"pages/mvp/mvp"
"pages/dev-tools/dev-tools"
],
"tabBar": {
"custom": true,
"color": "#8b8b8b",
"selectedColor": "#0052d9",
"backgroundColor": "#ffffff",
@@ -34,21 +29,15 @@
"list": [
{
"pagePath": "pages/task-list/task-list",
"text": "任务",
"iconPath": "assets/icons/tab-task.png",
"selectedIconPath": "assets/icons/tab-task-active.png"
"text": "任务"
},
{
"pagePath": "pages/board-finance/board-finance",
"text": "看板",
"iconPath": "assets/icons/tab-board.png",
"selectedIconPath": "assets/icons/tab-board-active.png"
"text": "看板"
},
{
"pagePath": "pages/my-profile/my-profile",
"text": "我的",
"iconPath": "assets/icons/tab-my.png",
"selectedIconPath": "assets/icons/tab-my-active.png"
"text": "我的"
}
]
},

View File

@@ -109,3 +109,176 @@ page {
.flex-1 {
flex: 1;
}
/* ============================================
* AI 图标配色系统(基于 docs/h5_ui/css/ai-icons.css
* 6 种配色 + 2 个系列inline-icon / title-badge
* ============================================ */
/* --- 默认配色(靛色 fallback --- */
.ai-inline-icon, .ai-title-badge {
--ai-from: #667eea;
--ai-to: #a78bfa;
--ai-from-deep: #4a5fc7;
--ai-to-deep: #667eea;
}
/* --- 6 种配色类 --- */
.ai-color-red { --ai-from: #e74c3c; --ai-to: #f39c9c; --ai-from-deep: #c0392b; --ai-to-deep: #e74c3c; }
.ai-color-orange { --ai-from: #e67e22; --ai-to: #f5c77e; --ai-from-deep: #ca6c17; --ai-to-deep: #e67e22; }
.ai-color-yellow { --ai-from: #d4a017; --ai-to: #f7dc6f; --ai-from-deep: #b8860b; --ai-to-deep: #d4a017; }
.ai-color-blue { --ai-from: #2980b9; --ai-to: #7ec8e3; --ai-from-deep: #1a5276; --ai-to-deep: #2980b9; }
.ai-color-indigo { --ai-from: #667eea; --ai-to: #a78bfa; --ai-from-deep: #4a5fc7; --ai-to-deep: #667eea; }
.ai-color-purple { --ai-from: #764ba2; --ai-to: #c084fc; --ai-from-deep: #5b3080; --ai-to-deep: #764ba2; }
/* --- 1. ai-inline-icon行首小图标H5 16px → 28rpx --- */
.ai-inline-icon {
display: inline-block;
width: 30rpx;
height: 30rpx;
/* color-mix 不支持用渐变近似45% from + white 混合 */
background: linear-gradient(135deg, var(--ai-from), var(--ai-to));
opacity: 0.65;
border-radius: 6rpx;
margin: 0 6rpx 0 0;
flex-shrink: 0;
position: relative;
overflow: hidden;
vertical-align: baseline;
transform: translateY(0.1em);
}
/* 内部机器人图片 */
.ai-inline-icon image {
width: 30rpx;
height: 30rpx;
position: relative;
z-index: 1;
flex-shrink: 0;
display: block;
}
/* 微光扫过动画12s 周期) */
.ai-inline-icon::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255,255,255,0.18) 45%,
rgba(255,255,255,0.22) 50%,
rgba(255,255,255,0.18) 55%,
transparent 70%
);
animation: ai-shimmer 12s ease-in-out infinite;
}
/* --- 2. ai-title-badge标题行右侧标识 --- */
.ai-title-badge {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 0rpx 18rpx 0rpx 6rpx;
/* 浅色背景:用低透明度渐变近似 color-mix 8%-10% */
background: linear-gradient(135deg,
rgba(102,126,234,0.08),
rgba(167,139,250,0.10)
);
border: 2rpx solid rgba(102,126,234,0.35);
border-radius: 18rpx;
font-size: 24rpx;
font-weight: 500;
color: var(--ai-from-deep);
white-space: nowrap;
position: relative;
overflow: hidden;
line-height: 1.4;
animation: ai-pulse 3s ease-in-out infinite;
}
/* 各配色的 badge 背景和边框覆盖 */
.ai-color-red .ai-title-badge,
.ai-title-badge.ai-color-red {
background: linear-gradient(135deg, rgba(231,76,60,0.08), rgba(243,156,156,0.10));
border-color: rgba(231,76,60,0.35);
}
.ai-color-orange .ai-title-badge,
.ai-title-badge.ai-color-orange {
background: linear-gradient(135deg, rgba(230,126,34,0.08), rgba(245,199,126,0.10));
border-color: rgba(230,126,34,0.35);
}
.ai-color-yellow .ai-title-badge,
.ai-title-badge.ai-color-yellow {
background: linear-gradient(135deg, rgba(212,160,23,0.08), rgba(247,220,111,0.10));
border-color: rgba(212,160,23,0.35);
}
.ai-color-blue .ai-title-badge,
.ai-title-badge.ai-color-blue {
background: linear-gradient(135deg, rgba(41,128,185,0.08), rgba(126,200,227,0.10));
border-color: rgba(41,128,185,0.35);
}
.ai-color-indigo .ai-title-badge,
.ai-title-badge.ai-color-indigo {
background: linear-gradient(135deg, rgba(102,126,234,0.08), rgba(167,139,250,0.10));
border-color: rgba(102,126,234,0.35);
}
.ai-color-purple .ai-title-badge,
.ai-title-badge.ai-color-purple {
background: linear-gradient(135deg, rgba(118,75,162,0.08), rgba(192,132,252,0.10));
border-color: rgba(118,75,162,0.35);
}
/* badge 内机器人图标容器 */
.ai-title-badge-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
flex-shrink: 0;
}
.ai-title-badge-icon image {
height: 36rpx;
}
/* 为 SVG 的 currentColor 设置颜色(描边和眼睛使用) */
.ai-color-red .ai-title-badge-icon { color: #c0392b; }
.ai-color-orange .ai-title-badge-icon { color: #ca6c17; }
.ai-color-yellow .ai-title-badge-icon { color: #b8860b; }
.ai-color-blue .ai-title-badge-icon { color: #1a5276; }
.ai-color-indigo .ai-title-badge-icon { color: #4a5fc7; }
.ai-color-purple .ai-title-badge-icon { color: #5b3080; }
/* 高光扫过14s 周期,复用 ai-shimmer */
.ai-title-badge::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255,255,255,0.15) 43%,
rgba(255,255,255,0.22) 50%,
rgba(255,255,255,0.15) 57%,
transparent 70%
);
animation: ai-shimmer 14s ease-in-out infinite;
}
/* --- 动画关键帧 --- */
@keyframes ai-shimmer {
0%, 100% { transform: translateX(-100%) rotate(45deg); }
50% { transform: translateX(100%) rotate(45deg); }
}
@keyframes ai-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(102,126,234,0); }
50% { box-shadow: 0 0 16rpx 4rpx rgba(102,126,234,0.35); }
}

View File

@@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 白色填充版机器人(用于 ai-inline-icon 渐变背景上) -->
<rect x="5" y="7" width="14" height="12" rx="4" fill="white"/>
<path d="M12 7V4" stroke="white" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="3" r="1.5" fill="white"/>
<circle cx="9" cy="11.5" r="2" fill="white" opacity="0.5"/>
<circle cx="15" cy="11.5" r="2" fill="white" opacity="0.5"/>
<circle cx="9.5" cy="11" r="0.7" fill="white"/>
<circle cx="15.5" cy="11" r="0.7" fill="white"/>
<path d="M9.5 15C10 16 14 16 14.5 15" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
<rect x="3" y="10" width="2" height="4" rx="1" fill="white"/>
<rect x="19" y="10" width="2" height="4" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 840 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
<!-- AI 机器人 title badge icon — indigo 配色,用于标题徽章 -->
<!-- 对齐 H5 ai-icons.css .ai-title-badge-icon, 默认 indigo: #667eea → #a78bfa -->
<rect x="5" y="7" width="14" height="12" rx="4" fill="white" stroke="currentColor" stroke-width="0.8"/>
<path d="M12 7V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="12" cy="3" r="1.5" fill="white" stroke="currentColor" stroke-width="0.7"/>
<circle cx="9" cy="11.5" r="2" fill="#667eea"/>
<circle cx="15" cy="11.5" r="2" fill="#667eea"/>
<circle cx="9.5" cy="11" r="0.7" fill="white"/>
<circle cx="15.5" cy="11" r="0.7" fill="white"/>
<path d="M9.5 15C10 16 14 16 14.5 15" stroke="#667eea" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="7" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<circle cx="17" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<rect x="3" y="10" width="2" height="4" rx="1" fill="white" stroke="currentColor" stroke-width="0.7"/>
<rect x="19" y="10" width="2" height="4" rx="1" fill="white" stroke="currentColor" stroke-width="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 153 B

After

Width:  |  Height:  |  Size: 153 B

View File

Before

Width:  |  Height:  |  Size: 294 B

After

Width:  |  Height:  |  Size: 294 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 150 B

After

Width:  |  Height:  |  Size: 150 B

View File

Before

Width:  |  Height:  |  Size: 213 B

After

Width:  |  Height:  |  Size: 213 B

View File

Before

Width:  |  Height:  |  Size: 187 B

After

Width:  |  Height:  |  Size: 187 B

View File

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

View File

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 341 B

View File

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 70 B

View File

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 70 B

View File

Before

Width:  |  Height:  |  Size: 194 B

After

Width:  |  Height:  |  Size: 194 B

View File

Before

Width:  |  Height:  |  Size: 194 B

After

Width:  |  Height:  |  Size: 194 B

View File

Before

Width:  |  Height:  |  Size: 194 B

After

Width:  |  Height:  |  Size: 194 B

View File

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 246 B

View File

Before

Width:  |  Height:  |  Size: 66 B

After

Width:  |  Height:  |  Size: 66 B

View File

Before

Width:  |  Height:  |  Size: 66 B

After

Width:  |  Height:  |  Size: 66 B

View File

Before

Width:  |  Height:  |  Size: 66 B

After

Width:  |  Height:  |  Size: 66 B

View File

Before

Width:  |  Height:  |  Size: 66 B

After

Width:  |  Height:  |  Size: 66 B

View File

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

View File

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -0,0 +1,28 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- ????? - ???? + ????? -->
<rect x="5" y="7" width="14" height="12" rx="4" fill="white" stroke="#1e3a5f" stroke-width="1.8" stroke-opacity="0.5"/>
<!-- ?? - ?? + ????? -->
<path d="M12 7V4" stroke="white" stroke-width="2.8" stroke-linecap="round"/>
<path d="M12 7V4" stroke="#1e3a5f" stroke-width="1.4" stroke-linecap="round" stroke-opacity="0.5"/>
<circle cx="12" cy="3" r="1.5" fill="white" stroke="#1e3a5f" stroke-width="1.2" stroke-opacity="0.5"/>
<!-- ?? -->
<circle cx="9" cy="11.5" r="2" fill="#667eea"/>
<circle cx="15" cy="11.5" r="2" fill="#667eea"/>
<!-- ???? -->
<circle cx="9.5" cy="11" r="0.7" fill="white"/>
<circle cx="15.5" cy="11" r="0.7" fill="white"/>
<!-- ???? -->
<path d="M9.5 15C10 16 14 16 14.5 15" stroke="#667eea" stroke-width="1.5" stroke-linecap="round"/>
<!-- ?? -->
<circle cx="7" cy="13.5" r="1" fill="#f5a0c0" opacity="0.5"/>
<circle cx="17" cy="13.5" r="1" fill="#f5a0c0" opacity="0.5"/>
<!-- ?? - ?? + ????? -->
<rect x="3" y="10" width="2" height="4" rx="1" fill="white" stroke="#1e3a5f" stroke-width="1.2" stroke-opacity="0.5"/>
<rect x="19" y="10" width="2" height="4" rx="1" fill="white" stroke="#1e3a5f" stroke-width="1.2" stroke-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
<!-- AI 机器人 inline icon — 白色版,用于深色背景行首 -->
<!-- 对齐 H5 task-list 中内联 SVG 结构 -->
<rect x="5" y="7" width="14" height="12" rx="4" fill="white"/>
<path d="M12 7V4" stroke="white" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="3" r="1.5" fill="white"/>
<circle cx="9" cy="11.5" r="2" fill="#667eea"/>
<circle cx="15" cy="11.5" r="2" fill="#667eea"/>
<circle cx="9.5" cy="11" r="0.7" fill="white"/>
<circle cx="15.5" cy="11" r="0.7" fill="white"/>
<path d="M9.5 15C10 16 14 16 14.5 15" stroke="#667eea" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="7" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<circle cx="17" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<rect x="3" y="10" width="2" height="4" rx="1" fill="white"/>
<rect x="19" y="10" width="2" height="4" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 983 B

View File

@@ -0,0 +1,18 @@
<!-- 黑色台球 - 大号8 -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ballGradient" cx="35%" cy="35%">
<stop offset="0%" style="stop-color:#3a3a3a;stop-opacity:1" />
<stop offset="70%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0f0f0f;stop-opacity:1" />
</radialGradient>
</defs>
<!-- 球体 -->
<circle cx="24" cy="24" r="18" fill="url(#ballGradient)"/>
<!-- 高光 -->
<ellipse cx="18" cy="16" rx="6" ry="4" fill="white" opacity="0.3"/>
<!-- 白色圆圈 -->
<circle cx="24" cy="24" r="8" fill="white"/>
<!-- 数字8 -->
<text x="24" y="29" font-family="Arial, sans-serif" font-size="11" font-weight="bold" fill="#1f2937" text-anchor="middle">8</text>
</svg>

After

Width:  |  Height:  |  Size: 833 B

View File

@@ -0,0 +1,18 @@
<!-- 灰色台球 - 大号8 -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ballGrayGradient" cx="35%" cy="35%">
<stop offset="0%" style="stop-color:#d0d0d0;stop-opacity:1" />
<stop offset="70%" style="stop-color:#c5c5c5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a6a6a6;stop-opacity:1" />
</radialGradient>
</defs>
<!-- 球体 -->
<circle cx="24" cy="24" r="18" fill="url(#ballGrayGradient)" opacity="0.5"/>
<!-- 高光 -->
<ellipse cx="18" cy="16" rx="6" ry="4" fill="white" opacity="0.3"/>
<!-- 白色圆圈 -->
<circle cx="24" cy="24" r="8" fill="white" opacity="0.6"/>
<!-- 数字8 -->
<text x="24" y="29" font-family="Arial, sans-serif" font-size="11" font-weight="bold" fill="#a6a6a6" text-anchor="middle" opacity="0.6">8</text>
</svg>

After

Width:  |  Height:  |  Size: 883 B

View File

@@ -0,0 +1,11 @@
<!-- 空心爱心 - 华丽版 -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="heartStroke" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff6b9d;stop-opacity:1" />
<stop offset="50%" style="stop-color:#e34d59;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c9184a;stop-opacity:1" />
</linearGradient>
</defs>
<path d="M24 42L20.55 38.85C11.4 30.48 5.25 24.87 5.25 18C5.25 12.39 9.39 8.25 15 8.25C18.09 8.25 21.06 9.69 23.25 12C25.44 9.69 28.41 8.25 31.5 8.25C37.11 8.25 41.25 12.39 41.25 18C41.25 24.87 35.1 30.48 25.95 38.85L24 42Z" stroke="url(#heartStroke)" stroke-width="2.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,22 @@
<!-- 实心爱心 - 华丽版 -->
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 外发光 -->
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="heartGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff6b9d;stop-opacity:1" />
<stop offset="50%" style="stop-color:#e34d59;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c9184a;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 主体爱心 -->
<path d="M24 42L20.55 38.85C11.4 30.48 5.25 24.87 5.25 18C5.25 12.39 9.39 8.25 15 8.25C18.09 8.25 21.06 9.69 23.25 12C25.44 9.69 28.41 8.25 31.5 8.25C37.11 8.25 41.25 12.39 41.25 18C41.25 24.87 35.1 30.48 25.95 38.85L24 42Z" fill="url(#heartGradient)" filter="url(#glow)"/>
<!-- 高光 -->
<path d="M15 8.25C12.5 8.25 10.3 9.2 8.8 10.8C10.5 9.5 12.6 8.8 15 8.8C17.5 8.8 19.8 9.8 21.5 11.5C20 9.5 17.7 8.25 15 8.25Z" fill="white" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="13" width="4" height="8" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="10" y="8" width="4" height="13" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="16" y="3" width="4" height="18" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="13" width="4" height="8" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="10" y="8" width="4" height="13" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="16" y="3" width="4" height="18" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 255 B

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" stroke="#8b8b8b" stroke-width="1.5"/>
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" stroke="#8b8b8b" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="4" width="14" height="17" rx="2" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="8" y="2" width="8" height="4" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="4" width="14" height="17" rx="2" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="8" y="2" width="8" height="4" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<path d="M9 12l2 2 4-4" stroke="#8b8b8b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 363 B

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#1a6dd9"/>
<stop offset="15%" stop-color="#1a6dd9"/>
<stop offset="40%" stop-color="#4087e9"/>
<stop offset="60%" stop-color="#4087e9"/>
<stop offset="85%" stop-color="#6ba8f8"/>
<stop offset="100%" stop-color="#6ba8f8"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,103 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<!-- 主渐变 theme-blue -->
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#002d80"/>
<stop offset="15%" stop-color="#002d80"/>
<stop offset="40%" stop-color="#0052d9"/>
<stop offset="60%" stop-color="#0052d9"/>
<stop offset="85%" stop-color="#2b7de9"/>
<stop offset="100%" stop-color="#2b7de9"/>
</linearGradient>
<!-- 丝绸光带 -->
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<!-- 左上角深色 -->
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<!-- 顶部深色 -->
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<!-- 左侧光晕 -->
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<!-- 右下角光晕 -->
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<!-- 底部高光 -->
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<!-- 右上角光晕 -->
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<!-- Aurora 丝带渐变 -->
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<!-- 主渐变 -->
<rect width="750" height="580" fill="url(#main)"/>
<!-- 丝绸光带 -->
<rect width="750" height="580" fill="url(#silk)"/>
<!-- 左上角深色 -->
<rect width="750" height="580" fill="url(#darkTL)"/>
<!-- 顶部深色 -->
<rect width="750" height="580" fill="url(#darkTop)"/>
<!-- 左侧光晕 -->
<rect width="750" height="580" fill="url(#glowL)"/>
<!-- 右下角光晕 -->
<rect width="750" height="580" fill="url(#glowBR)"/>
<!-- 底部高光 -->
<rect width="750" height="580" fill="url(#edgeHL)"/>
<!-- 右上角光晕 -->
<rect width="750" height="580" fill="url(#glowTR)"/>
<!-- Aurora 丝带
原始 CSS: viewBox 400×200, background-size:480px 160px, background-position:center 40px, opacity:0.5
H5 画布 412px 宽 → 小程序 750rpx 宽,缩放比 750/412=1.820
x: (412-480)/2=-34 → -34×1.820=-61.9
y: 40px → 40×1.820=72.8
scale.x: 1.2×1.820=2.184
scale.y: 0.8×1.820=1.456
-->
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#be4a62"/>
<stop offset="15%" stop-color="#be4a62"/>
<stop offset="40%" stop-color="#d4617a"/>
<stop offset="60%" stop-color="#d4617a"/>
<stop offset="85%" stop-color="#e8899a"/>
<stop offset="100%" stop-color="#e8899a"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<!-- theme-dark-gold: 深色黑金,右下角金色光晕 -->
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#1a1a1a"/>
<stop offset="40%" stop-color="#2a2520"/>
<stop offset="100%" stop-color="#1f1c18"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.03"/>
<stop offset="45%" stop-color="white" stop-opacity="0.08"/>
<stop offset="55%" stop-color="white" stop-opacity="0.08"/>
<stop offset="80%" stop-color="white" stop-opacity="0.03"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.3"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.2"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.06"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<!-- 金色右下光晕 (::after dark-gold 特殊处理) -->
<radialGradient id="glowGold" cx="0.6" cy="0.7" r="0.6" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="#ffc400" stop-opacity="0.25"/>
<stop offset="50%" stop-color="#ffbb00" stop-opacity="0"/>
</radialGradient>
<linearGradient id="goldDiag" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffbb00" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffbb00" stop-opacity="0.15"/>
<stop offset="60%" stop-color="#ffb700" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffb700" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ffc400" stop-opacity="0.1"/>
<stop offset="25%" stop-color="#ffc400" stop-opacity="0"/>
</linearGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.3"/>
<stop offset="50%" stop-color="white" stop-opacity="0.1"/>
<stop offset="100%" stop-color="white" stop-opacity="0.2"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.2"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.25"/>
<stop offset="70%" stop-color="white" stop-opacity="0.25"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<!-- 金色光晕 -->
<rect width="750" height="580" fill="url(#glowGold)"/>
<rect width="750" height="580" fill="url(#goldDiag)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<!-- Aurora 丝带opacity 降低,配合深色主题) -->
<g opacity="0.35" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.15" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#9a3412"/>
<stop offset="15%" stop-color="#9a3412"/>
<stop offset="40%" stop-color="#ea580c"/>
<stop offset="60%" stop-color="#ea580c"/>
<stop offset="85%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#f97316"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#9d174d"/>
<stop offset="15%" stop-color="#9d174d"/>
<stop offset="40%" stop-color="#db2777"/>
<stop offset="60%" stop-color="#db2777"/>
<stop offset="85%" stop-color="#ec4899"/>
<stop offset="100%" stop-color="#ec4899"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#991b1b"/>
<stop offset="15%" stop-color="#991b1b"/>
<stop offset="40%" stop-color="#dc2626"/>
<stop offset="60%" stop-color="#dc2626"/>
<stop offset="85%" stop-color="#ef4444"/>
<stop offset="100%" stop-color="#ef4444"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 580" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="main" x1="0" y1="1" x2="0.8" y2="0">
<stop offset="0%" stop-color="#0f766e"/>
<stop offset="15%" stop-color="#0f766e"/>
<stop offset="40%" stop-color="#14b8a6"/>
<stop offset="60%" stop-color="#14b8a6"/>
<stop offset="85%" stop-color="#2dd4bf"/>
<stop offset="100%" stop-color="#2dd4bf"/>
</linearGradient>
<linearGradient id="silk" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="20%" stop-color="white" stop-opacity="0.04"/>
<stop offset="45%" stop-color="white" stop-opacity="0.12"/>
<stop offset="55%" stop-color="white" stop-opacity="0.12"/>
<stop offset="80%" stop-color="white" stop-opacity="0.04"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="darkTL" cx="-0.1" cy="-0.05" r="0.75" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="black" stop-opacity="0.25"/>
<stop offset="45%" stop-color="black" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkTop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="black" stop-opacity="0.18"/>
<stop offset="40%" stop-color="black" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowL" cx="0.05" cy="0.4" r="0.65" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.12"/>
<stop offset="50%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowBR" cx="0.85" cy="0.95" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.25"/>
<stop offset="55%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edgeHL" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="0.15"/>
<stop offset="25%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glowTR" cx="0.85" cy="0.1" r="0.55" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="40%" stop-color="white" stop-opacity="0.1"/>
<stop offset="65%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="r1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.4"/>
<stop offset="50%" stop-color="white" stop-opacity="0.15"/>
<stop offset="100%" stop-color="white" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="r2" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="black" stop-opacity="0.15"/>
<stop offset="50%" stop-color="white" stop-opacity="0.2"/>
<stop offset="100%" stop-color="black" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="r3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="30%" stop-color="white" stop-opacity="0.35"/>
<stop offset="70%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<filter id="aBlur"><feGaussianBlur stdDeviation="2"/></filter>
</defs>
<rect width="750" height="580" fill="url(#main)"/>
<rect width="750" height="580" fill="url(#silk)"/>
<rect width="750" height="580" fill="url(#darkTL)"/>
<rect width="750" height="580" fill="url(#darkTop)"/>
<rect width="750" height="580" fill="url(#glowL)"/>
<rect width="750" height="580" fill="url(#glowBR)"/>
<rect width="750" height="580" fill="url(#edgeHL)"/>
<rect width="750" height="580" fill="url(#glowTR)"/>
<g opacity="0.5" transform="translate(-62,73) scale(2.184,1.456)">
<path d="M-50 180 Q80 100 180 140 T350 100 T500 140" fill="none" stroke="url(#r2)" stroke-width="60" stroke-linecap="round" filter="url(#aBlur)"/>
<path d="M-30 50 Q100 120 200 70 T380 110 T520 60" fill="none" stroke="url(#r1)" stroke-width="45" stroke-linecap="round"/>
<path d="M0 120 Q120 60 220 100 T420 70" fill="none" stroke="url(#r3)" stroke-width="25" stroke-linecap="round"/>
<path d="M50 90 Q150 150 280 90 T450 120" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 95" width="95" height="95">
<defs>
<filter id="textBlur">
<feGaussianBlur stdDeviation="0.73"/>
</filter>
<!-- 内圆填充:从边框向圆心的单向光路,边缘不透明→圆心透明 -->
<radialGradient id="innerLight" cx="50%" cy="50%" r="50%" gradientUnits="objectBoundingBox">
<stop offset="0%" stop-color="white" stop-opacity="0"/>
<stop offset="60%" stop-color="white" stop-opacity="0.18"/>
<stop offset="82%" stop-color="white" stop-opacity="0.38"/>
<stop offset="95%" stop-color="white" stop-opacity="0.75"/>
<stop offset="100%" stop-color="white" stop-opacity="0.95"/>
</radialGradient>
</defs>
<rect width="95" height="95" fill="transparent"/>
<!-- 外圆:更鲜艳红色 -->
<circle cx="47.5" cy="47.5" r="44.75"
fill="transparent"
stroke="rgb(255, 45, 45)"
stroke-width="5.5"/>
<!-- 内圆填充:从边框向圆心的光路渐变 -->
<circle cx="47.5" cy="47.5" r="40.25"
fill="url(#innerLight)"/>
<!-- 内圆:纯白描边,无发光 -->
<circle cx="47.5" cy="47.5" r="40.25"
fill="none"
stroke="rgb(255, 255, 255)"
stroke-width="3.65"/>
<!-- 👍 Emoji -->
<text x="47.5" y="33"
font-size="33"
text-anchor="middle"
dominant-baseline="middle"
fill="#000">👍</text>
<!-- 白色描边层 -->
<text x="47.5" y="72"
font-family="sans-serif, -apple-system, BlinkMacSystemFont"
font-size="20"
font-weight="bold"
text-anchor="middle"
fill="rgba(255,255,255,0.95)"
stroke="rgba(255,255,255,0.95)"
stroke-width="4"
paint-order="stroke">已完成</text>
<!-- 模糊白色光晕层 -->
<text x="47.5" y="72"
font-family="sans-serif, -apple-system, BlinkMacSystemFont"
font-size="20"
font-weight="bold"
text-anchor="middle"
fill="rgba(255,255,255,0.9)"
opacity="0.9"
filter="url(#textBlur)">已完成</text>
<!-- 红色文字主体:更鲜艳 -->
<text x="47.5" y="72"
font-family="sans-serif, -apple-system, BlinkMacSystemFont"
font-size="20"
font-weight="bold"
text-anchor="middle"
fill="rgb(235, 25, 25)">已完成</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -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() {},
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -50,8 +50,7 @@
/* SVG 图标 H5: 30px → 52rpx */
.ai-icon-svg {
width: 52rpx;
height: 52rpx;
height: 60rpx;
position: relative;
z-index: 1;
}

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -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',
},
},
})

View File

@@ -0,0 +1,3 @@
<view class="ai-inline-icon ai-color-{{color}}">
<image src="/assets/icons/ai-robot-inline.svg" mode="aspectFit" />
</view>

View File

@@ -0,0 +1,4 @@
/* 引入全局 AI 样式 */
@import "../../app.wxss";
/* 组件内样式继承全局定义 */

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -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智能洞察',
},
},
})

View File

@@ -0,0 +1,6 @@
<view class="ai-title-badge ai-color-{{color}}">
<view class="ai-title-badge-icon">
<image src="/assets/icons/ai-robot-badge.svg" mode="aspectFit" />
</view>
<text>{{label}}</text>
</view>

View File

@@ -0,0 +1,4 @@
/* 引入全局 AI 样式 */
@import "../../app.wxss";
/* 组件内样式继承全局定义 */

View File

@@ -1,6 +1,6 @@
<!-- 筛选下拉组件 — 全屏宽度面板 + 遮罩层 -->
<view class="filter-dropdown-wrap" wx:if="{{options && options.length > 0}}">
<view class="filter-dropdown {{expanded ? 'filter-dropdown--active' : ''}}" bindtap="toggleDropdown">
<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 File

@@ -6,15 +6,17 @@
}
/* 触发按钮 */
/* 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 20rpx;
background: #ffffff;
border-radius: var(--radius-md);
border: 2rpx solid var(--color-gray-1);
padding: 16rpx 22rpx;
background: #f9fafb;
border-radius: 14rpx;
border: 2rpx solid #f3f4f6;
transition: border-color 0.2s, background-color 0.2s;
}
@@ -23,17 +25,24 @@
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: 600;
font-weight: 500;
}
.filter-dropdown--active .filter-label {
color: var(--color-primary);
font-weight: 600;
}
/* 已选中筛选项 — 粗字体标记当前选择 */
.filter-dropdown--selected .filter-label {
font-weight: 600;
}
/* 箭头旋转动画 */
@@ -99,7 +108,7 @@
.dropdown-item--active {
color: var(--color-primary, #0052d9);
font-weight: 500;
font-weight: 600;
}
.dropdown-item + .dropdown-item {

View File

@@ -16,13 +16,13 @@ Component({
const s = val < 0 ? 0 : val > 10 ? 10 : val
let emoji: string
if (s > 8.5) {
emoji = '💖'
} else if (s > 7) {
emoji = '🧡'
} else if (s > 5) {
emoji = '💛'
emoji = '💖' // 粉红色 - 很好
} else if (s >= 6) {
emoji = '🧡' // 橙色 - 良好
} else if (s >= 3.5) {
emoji = '💛' // 黄色 - 一般
} else {
emoji = '💙'
emoji = '💙' // 蓝色 - 待发展
}
this.setData({ heartEmoji: emoji })
},

View File

@@ -1,74 +1,231 @@
Component({
properties: {
/** 控制弹窗显示/隐藏 */
visible: {
type: Boolean,
value: false,
},
/** 客户名(弹窗标题显示) */
customerName: {
type: String,
value: '',
},
/** 初始评分 0-10 */
initialScore: {
type: Number,
value: 0,
},
/** 初始备注内容 */
initialContent: {
type: String,
value: '',
},
},
observers: {
'visible, initialScore, initialContent'(visible: boolean) {
if (visible) {
const clamped = Math.max(0, Math.min(10, this.data.initialScore))
this.setData({
starValue: clamped / 2,
content: this.data.initialContent,
score: clamped,
})
}
/** 是否显示展开/收起按钮 */
showExpandBtn: {
type: Boolean,
value: true,
},
},
data: {
/** 星星值 0-5半星制 */
starValue: 0,
/** 内部评分 0-10 */
score: 0,
/** 备注内容 */
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.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'(
serviceScore: number,
returnScore: number,
content: string
) {
this.setData({
canSave: serviceScore > 0 && returnScore > 0 && content.trim().length > 0,
})
},
},
methods: {
/** 星星评分变化 */
onRateChange(e: WechatMiniprogram.CustomEvent<{ value: number }>) {
const starVal = e.detail.value
this.setData({
starValue: starVal,
score: starVal * 2,
/** 缓存容器位置信息 */
_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
})
},
/** 文本内容变化 */
onContentChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
/** 重试获取容器位置,确保首次打开也能正确缓存 */
_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.content.trim()) return
if (!this.data.canSave) return
// 计算综合评分取两个评分的平均值转换为10分制
const avgScore = (this.data.serviceScore + this.data.returnScore) / 2
const finalScore = avgScore * 2 // 转换为10分制
this.triggerEvent('confirm', {
score: this.data.score,
score: finalScore,
content: this.data.content.trim(),
serviceScore: this.data.serviceScore,
returnScore: this.data.returnScore,
})
},
/** 取消关闭 */
/** 取消 */
onCancel() {
this.triggerEvent('cancel')
},

View File

@@ -1,46 +1,83 @@
<view class="modal-mask" wx:if="{{visible}}" catchtap="onCancel">
<view class="modal-content" catchtap="noop">
<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">
<text class="modal-title">添加备注{{customerName ? ' - ' + customerName : ''}}</text>
<view class="modal-close" bindtap="onCancel">
<t-icon name="close" size="40rpx" color="#a6a6a6" />
<view class="header-left">
<text class="modal-title">添加备注</text>
<view class="expand-btn" wx:if="{{showExpandBtn}}" 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">
<text class="rating-label">评分</text>
<t-rate
value="{{starValue}}"
count="{{5}}"
allow-half="{{true}}"
size="48rpx"
bind:change="onRateChange"
/>
<view class="rating-section" wx:if="{{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">
<t-textarea
value="{{content}}"
<textarea
class="note-textarea"
placeholder="请输入备注内容..."
maxlength="{{500}}"
autosize="{{true}}"
bind:change="onContentChange"
value="{{content}}"
bindinput="onContentInput"
bindfocus="onTextareaFocus"
bindblur="onTextareaBlur"
maxlength="500"
auto-height
adjust-position="{{false}}"
placeholder-class="textarea-placeholder"
/>
</view>
<!-- 底部按钮 -->
<view class="modal-footer">
<t-button theme="default" size="large" block bindtap="onCancel">取消</t-button>
<t-button
theme="primary"
size="large"
block
disabled="{{!content.trim()}}"
bindtap="onConfirm"
>确认</t-button>
<!-- 键盘弹出时的占位,防止内容被遮挡 -->
<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>

View File

@@ -1,23 +1,39 @@
.modal-mask {
/* 备注弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
z-index: 1000;
transition: align-items 0.3s ease;
}
.modal-content {
/* 键盘弹出时,弹窗移到顶部 */
.modal-overlay--keyboard-open {
align-items: flex-start;
}
.modal-container {
width: 100%;
background: #ffffff;
background: #fff;
border-radius: 48rpx 48rpx 0 0;
padding: 40rpx 40rpx 60rpx;
box-sizing: border-box;
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;
@@ -25,10 +41,34 @@
margin-bottom: 32rpx;
}
.header-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.modal-title {
font-size: var(--font-lg, 36rpx);
font-size: 32rpx;
line-height: 44rpx;
font-weight: 600;
color: var(--color-gray-13, #242424);
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 {
@@ -38,31 +78,164 @@
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-gray-1, #f3f3f3);
background: transparent;
}
.modal-close--hover {
background: #f3f3f3;
}
/* 评分区域 */
.rating-section {
display: flex;
align-items: center;
gap: 24rpx;
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 {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
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;
}
.textarea-section {
margin-bottom: 40rpx;
.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;
gap: 24rpx;
}
.modal-footer t-button {
flex: 1;
/* 键盘弹出时固定在键盘上方 */
.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;
}

View File

@@ -1,6 +1,6 @@
Component({
properties: {
/** 评分 0-10内部转换为 0-5 星 */
/** 评分 0-10内部转换为 0-5 星,支持半星 */
score: {
type: Number,
value: 0,
@@ -19,7 +19,7 @@ Component({
observers: {
score(val: number) {
// score(0-10) → star(0-5)
// score(0-10) → star(0-5),支持半星(如 7.5 → 3.75星)
const clamped = Math.max(0, Math.min(10, val))
this.setData({ starValue: clamped / 2 })
},

View File

@@ -3,5 +3,7 @@
count="{{5}}"
allow-half
size="{{size}}"
color="#fbbf24"
gap="{{4}}"
disabled="{{readonly}}"
/>

View File

@@ -1,3 +1,4 @@
/* 星星评分组件样式 */
:host {
display: inline-block;
}

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,58 @@
/**
* 自定义 tabBar 组件
*
* 微信 custom-tab-bar 机制tabBar 页面由框架自动挂载;
* 非 tabBar 页面(如 board-customer/board-coach可手动引用。
*
* 支持 2/3 按钮动态布局,权限数据由外部注入(当前 mock 为 3 按钮)。
*/
/** tab 路由映射key → url + 是否为 tabBar 页面) */
const TAB_ROUTES: Record<string, { url: string; isTabBarPage: boolean }> = {
task: { url: '/pages/task-list/task-list', isTabBarPage: true },
board: { url: '/pages/board-finance/board-finance', isTabBarPage: true },
my: { url: '/pages/my-profile/my-profile', isTabBarPage: true },
}
// TODO: 联调时从全局状态/接口获取权限,过滤可见 tab
// 示例const visibleKeys = getApp().globalData.visibleTabs || ['task', 'board', 'my']
const VISIBLE_KEYS = ['task', 'board', 'my']
/** 根据权限过滤后的 tab 列表 */
const TABS = [
{ key: 'task', label: '任务', icon: '/assets/icons/tab-task-nav.svg', activeIcon: '/assets/icons/tab-task-nav-active.svg' },
{ key: 'board', label: '看板', icon: '/assets/icons/tab-board-nav.svg', activeIcon: '/assets/icons/tab-board-nav-active.svg' },
{ key: 'my', label: '我的', icon: '/assets/icons/tab-my-nav.svg', activeIcon: '/assets/icons/tab-my-nav-active.svg' },
].filter((t) => VISIBLE_KEYS.includes(t.key))
Component({
properties: {
/** 当前激活的 tab key */
active: {
type: String,
value: '',
},
},
data: {
tabs: TABS,
tabCount: TABS.length,
},
methods: {
onTap(e: WechatMiniprogram.TouchEvent) {
const key = e.currentTarget.dataset.key as string
// 通过 properties 获取 active避免 this.data 类型推断问题
if (key === (this as unknown as { data: { active: string } }).data.active) return
const route = TAB_ROUTES[key]
if (!route) return
if (route.isTabBarPage) {
wx.switchTab({ url: route.url })
} else {
wx.navigateTo({ url: route.url })
}
},
},
})

View File

@@ -0,0 +1,17 @@
<!-- 自定义 tabBar — 支持 2/3 按钮动态布局 -->
<view class="tab-bar tab-bar--{{tabCount}}">
<view
wx:for="{{tabs}}"
wx:key="key"
class="tab-bar-item {{active === item.key ? 'tab-bar-item--active' : ''}}"
bindtap="onTap"
data-key="{{item.key}}"
>
<image
class="tab-bar-icon"
src="{{active === item.key ? item.activeIcon : item.icon}}"
mode="aspectFit"
/>
<text class="tab-bar-label">{{item.label}}</text>
</view>
</view>

View File

@@ -0,0 +1,49 @@
/* 自定义 tabBar — 统一底部导航样式 */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
height: 100rpx;
background: #ffffff;
border-top: 1rpx solid #eeeeee;
padding-bottom: env(safe-area-inset-bottom);
z-index: 999;
}
/* 3 按钮:等宽三分 */
.tab-bar--3 .tab-bar-item {
flex: 1;
}
/* 2 按钮:各占一半,内容居中 */
.tab-bar--2 .tab-bar-item {
width: 50%;
flex: none;
}
.tab-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
height: 100%;
}
.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;
}

View File

@@ -12,7 +12,7 @@ Page({
},
onLoad() {
const { statusBarHeight } = wx.getSystemInfoSync()
const { statusBarHeight } = wx.getWindowInfo()
this.setData({ statusBarHeight })
},

View File

@@ -1,13 +1,16 @@
{
"navigationBarTitleText": "助教看板",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/components/board-tab-bar/board-tab-bar",
"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"
"t-tag": "tdesign-miniprogram/tag/tag",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -108,7 +108,7 @@ const MOCK_COACHES: CoachItem[] = [
taskRecall: '15', taskCallback: '13',
},
{
id: 'c3', name: 'Amy', initial: 'A',
id: 'c3', name: 'Lucy', initial: 'A',
avatarGradient: 'avatar--pink',
level: '星级', levelClass: LEVEL_CLASS['星级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
@@ -155,7 +155,7 @@ const MOCK_COACHES: CoachItem[] = [
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'coach' as 'finance' | 'customer' | 'coach',
selectedSort: 'perf_desc',
@@ -256,6 +256,10 @@ Page({
this.setData({ selectedTime: e.detail.value })
},
onRetry() {
this.loadData()
},
onCoachTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/coach-detail/coach-detail?id=' + id })

View File

@@ -10,6 +10,12 @@
<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 -->
@@ -63,6 +69,7 @@
wx:key="id"
data-id="{{item.id}}"
bindtap="onCoachTap"
hover-class="coach-card--hover"
>
<view class="card-row">
<!-- 头像 -->
@@ -148,6 +155,6 @@
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 — 在导航栏上方 -->
<ai-float-button bottom="{{220}}" />
<ai-float-button />
<dev-fab />
<dev-fab wx:if="{{false}}" />

View File

@@ -2,13 +2,24 @@
/* ===== 三态 ===== */
.page-loading,
.page-empty {
.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;
@@ -19,11 +30,13 @@
z-index: 20;
}
/* text-sm=14px→24rpx, line-height 匹配 Tailwind 默认 1.25rem=20px→36rpx */
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
padding: 22rpx 0;
font-size: 24rpx;
line-height: 34rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
@@ -47,12 +60,12 @@
border-radius: 3rpx;
}
/* ===== 筛选区域 px-4=16px→28rpx, py-2=8px→14rpx ===== */
/* px-4=16px→28rpx, py-2=8px→14rpx, sticky top-[44px]→77rpx */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 70rpx;
top: 77rpx;
z-index: 15;
border-bottom: 2rpx solid #eeeeee;
transition: transform 220ms ease, opacity 220ms ease;
@@ -70,7 +83,7 @@
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 14rpx;
border-radius: 16rpx;
padding: 10rpx;
border: 2rpx solid #eeeeee;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
@@ -82,36 +95,36 @@
}
.filter-item--wide {
flex: 1.8;
flex: 2;
}
/* ===== 助教列表 p-4=16px→28rpx, space-y-3=12px→20rpx ===== */
/* p-4=16px→28rpx, space-y-3=12px→22rpx — 四边一致 */
.coach-list {
padding: 24rpx 28rpx;
padding: 13rpx 28rpx 28rpx 28rpx;
}
/* ===== 助教卡片 p-4=16px→28rpx, rounded-2xl=16px→28rpx ===== */
/* p-4=16px→28rpx, rounded-2xl=16px→32rpxborder-radius: px×2不乘0.875 */
.coach-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx;
margin-bottom: 20rpx;
border-radius: 32rpx;
padding: 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.coach-card:active {
.coach-card--hover {
opacity: 0.96;
transform: scale(0.98);
}
/* gap-3=12px→20rpx(视觉校准紧凑) */
/* gap-3=12px→20rpx, items-center 忠于 H5 原型 */
.card-row {
display: flex;
align-items: flex-start;
align-items: center;
gap: 20rpx;
}
/* ===== 头像 w-11 h-11=44px→78rpx, text-base=16px→28rpx ===== */
/* w-11 h-11=44px→78rpx, text-base=16px→28rpx — items-center 对齐,无需 margin-top */
.card-avatar {
width: 78rpx;
height: 78rpx;
@@ -120,7 +133,6 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.avatar-text {
@@ -147,13 +159,14 @@
.card-name-row {
display: flex;
align-items: center;
gap: 8rpx;
gap: 10rpx;
flex-wrap: nowrap;
}
/* text-base=16px→28rpx */
/* 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;
@@ -163,7 +176,7 @@
.level-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
border-radius: 8rpx;
flex-shrink: 0;
font-weight: 500;
}
@@ -192,7 +205,7 @@
.skill-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
@@ -201,34 +214,34 @@
.skill--mahjong { background: rgba(237, 123, 47, 0.1); color: #ed7b2f; }
.skill--karaoke { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
/* ===== 卡片右侧指标ml-auto 推到右边) ===== */
/* ml-auto, gap-2=8px→14rpx */
.card-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10rpx;
gap: 14rpx;
flex-shrink: 0;
white-space: nowrap;
}
/* text-xs=12px→22rpx — "定档"标签文字,普通粗细 */
/* text-xs=12px→22rpx — "定档"标签文字 */
.right-text {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 400;
}
/* text-sm=14px→24rpx — 数值加粗 */
/* 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: 20rpx;
color: #c5c5c5;
font-size: 22rpx;
color: #a6a6a6;
font-weight: 400;
}
@@ -246,26 +259,27 @@
border-radius: 6rpx;
}
/* text-lg=18px→32rpx — 储值维度缩小避免挤压客户列表 */
/* text-lg=18px→32rpx, line-height: 28px→50rpx匹配 Tailwind text-lg 的 1.75rem */
.salary-amount {
font-size: 28rpx;
font-size: 32rpx;
line-height: 50rpx;
font-weight: 700;
color: #242424;
}
/* ===== 第二行 mt-1.5=6px→12rpx, text-xs=12px→22rpx ===== */
/* mt-1.5=6px→10rpx, text-xs=12px→22rpx */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
margin-top: 10rpx;
}
/* gap-2=8px→12rpx */
/* gap-2=8px→14rpx */
.customer-list {
display: flex;
align-items: center;
gap: 12rpx;
gap: 14rpx;
overflow: hidden;
flex: 1;
min-width: 0;

View File

@@ -1,11 +1,14 @@
{
"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": "/components/board-tab-bar/board-tab-bar",
"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",

View File

@@ -111,7 +111,7 @@ const MOCK_CUSTOMERS: CustomerItem[] = [
{ text: '高客单', theme: 'warning' },
],
spend30d: '¥4,200', avgVisits: '6.2次', avgSpend: '¥680',
lastVisit: '3天前', monthlyConsume: '¥3,500', availableMonths: '0.8月',
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',
@@ -140,23 +140,23 @@ const MOCK_CUSTOMERS: CustomerItem[] = [
{ text: '低频', theme: 'gray' },
],
spend30d: '¥1,800', avgVisits: '2.1次', avgSpend: '¥860',
lastVisit: '12天前', monthlyConsume: '¥1,800', availableMonths: '4.6月',
lastVisit: '12天前', monthlyConsume: '¥1,800', availableMonths: '4.6月',
lastRecharge: '1月20日', rechargeAmount: '¥10,000', recharges60d: '1次', currentBalance: '¥8,200',
spend60d: '¥3,600', visits60d: '4', highSpendTag: false,
avgInterval: '15.0天', intimacy: '68',
topCoachName: 'Amy', topCoachHeart: 8.5, topCoachScore: '8.5',
topCoachName: 'Lucy', topCoachHeart: 8.5, topCoachScore: '8.5',
coachDetails: [
{ name: 'Amy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', avgDuration: '1.8h', serviceCount: '10', coachSpend: '¥3,600', relationIdx: 8.5 },
{ name: '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: 'Amy', coachRatio: '62%',
], coachName: 'Lucy', coachRatio: '62%',
visitFreq: '2.1次/月',
daysAgo: 12,
assistants: [
{ name: 'Amy', cls: 'assistant--assignee', heartScore: 8.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
{ 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' },
],
},
@@ -168,7 +168,7 @@ const MOCK_CUSTOMERS: CustomerItem[] = [
{ text: '高频', theme: 'primary' },
],
spend30d: '¥3,500', avgVisits: '8.0次', avgSpend: '¥440',
lastVisit: '1天前', monthlyConsume: '¥3,200', availableMonths: '0.4月',
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',
@@ -176,7 +176,7 @@ const MOCK_CUSTOMERS: CustomerItem[] = [
coachDetails: [
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', avgDuration: '2.1h', serviceCount: '11', coachSpend: '¥3,300', relationIdx: 9.5 },
{ name: '小燕', cls: 'assistant--normal', heartScore: 6.8, avgDuration: '1.3h', serviceCount: '6', coachSpend: '¥1,800', relationIdx: 6.8 },
{ name: 'Amy', cls: 'assistant--abandoned', heartScore: 3.2, badge: '弃', avgDuration: '0.5h', serviceCount: '2', coachSpend: '¥400', relationIdx: 3.2 },
{ 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 },
@@ -192,7 +192,7 @@ const MOCK_CUSTOMERS: CustomerItem[] = [
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'customer' as 'finance' | 'customer' | 'coach',
selectedDimension: 'recall',
@@ -272,6 +272,10 @@ Page({
}, 400)
},
onRetry() {
this.loadData()
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'finance') {

View File

@@ -10,6 +10,12 @@
<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 -->
@@ -60,13 +66,14 @@
<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">
<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>
@@ -76,7 +83,7 @@
<text class="card-name">{{item.name}}</text>
<view class="card-name-sub">
<text class="mid-text" style="white-space: nowrap;">平均间隔 <text class="mid-primary">{{item.avgInterval}}</text></text>
<text class="mid-text" style="margin-left: 16rpx; white-space: nowrap;">60天消费 <text class="mid-dark">{{item.spend60d}}</text></text>
<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>
@@ -154,7 +161,7 @@
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'potential'}}">
<view class="grid-cell">
<text class="grid-label">30天消费</text>
<text class="grid-val grid-val--warning grid-val--lg">{{item.spend30d}}</text>
<text class="grid-val grid-val--lg">{{item.spend30d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均到店</text>
@@ -232,7 +239,7 @@
</view>
<view class="mini-chart-bars">
<view class="mini-bar-col" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">
<view class="mini-bar" style="height:{{wv.pct}}%;opacity:{{0.2 + wv.pct * 0.006}}"></view>
<view class="mini-bar" style="height:{{wv.pct}}%;opacity:{{0.2 + wIdx * 0.057}}"></view>
</view>
</view>
<view class="mini-chart-nums">
@@ -269,7 +276,7 @@
<view class="card-mid-row" wx:elif="{{dimType === 'recent'}}">
<text class="mid-text">理想间隔 <text class="mid-dark">{{item.idealDays}}天</text></text>
<text class="mid-text mid-ml">60天到店 <text class="mid-dark">{{item.visits60d}}天</text></text>
<text class="mid-text mid-ml">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
<text class="mid-text mid-ml-right">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
</view>
<!-- ===== 卡片底部:助教行(最专一维度不显示,因为助教信息在表格中) ===== -->
@@ -296,6 +303,6 @@
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{220}}" />
<ai-float-button />
<dev-fab />
<dev-fab wx:if="{{false}}" />

View File

@@ -2,18 +2,30 @@
/* ===== 三态 ===== */
.page-loading,
.page-empty {
.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: 2rpx solid #eeeeee;
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
position: sticky;
top: 0;
z-index: 20;
@@ -22,8 +34,9 @@
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
padding: 22rpx 0;
font-size: 26rpx; /* P1-6: 修正 14px → 26rpx */
line-height: 36rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
@@ -40,20 +53,20 @@
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 42rpx;
height: 5rpx;
width: 44rpx; /* 补充修正24px → 44rpx */
height: 6rpx; /* 补充修正3px → 6rpx */
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 3rpx;
border-radius: 4rpx; /* 补充修正2px → 4rpx */
}
/* ===== 筛选区域(对齐 board-coach 规范) ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
padding: 14rpx 30rpx; /* P0-3: 修正 16px → 30rpx */
position: sticky;
top: 70rpx;
z-index: 15;
border-bottom: 2rpx solid #eeeeee;
border-bottom: 1px solid #eeeeee; /* P2-13: 发丝线用 px */
transition: transform 220ms ease, opacity 220ms ease;
}
@@ -68,9 +81,9 @@
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 14rpx;
padding: 10rpx;
border: 2rpx solid #eeeeee;
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);
}
@@ -80,7 +93,7 @@
}
.filter-item--wide {
flex: 1.8;
flex: 1;
}
/* ===== 列表头部 ===== */
@@ -88,47 +101,50 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 28rpx 12rpx;
padding: 30rpx 36rpx 14rpx; /* P0-1: 修正 */
}
.list-header-left {
display: flex;
align-items: center;
gap: 8rpx;
gap: 14rpx; /* P1-11: 修正 8px → 14rpx */
}
.list-header-title {
font-size: 28rpx;
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: 24rpx;
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #a6a6a6;
}
.list-header-count {
font-size: 24rpx;
font-size: 26rpx; /* P1-8: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #c5c5c5;
}
/* ===== 客户列表 ===== */
.customer-list {
padding: 0 28rpx 24rpx;
margin-top: 4rpx;
padding: 0 30rpx 30rpx; /* P0-4: 修正 */
margin-top: 8rpx; /* 修正 4px → 8rpx */
}
/* ===== 客户卡片 ===== */
.customer-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx 26rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
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:active {
.customer-card--hover {
opacity: 0.96;
transform: scale(0.98);
}
@@ -138,13 +154,17 @@
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 6rpx;
}
/* 网格/表格维度头部间距更大 */
.card-header--lg {
margin-bottom: 2rpx; /* H5 mb-3=12px → 22rpx */
}
.card-avatar {
width: 66rpx;
height: 66rpx;
border-radius: 14rpx;
width: 66rpx; /* 补充修正36px → 66rpx */
height: 66rpx; /* 补充修正36px → 66rpx */
border-radius: 22rpx; /* 补充修正12px → 22rpx */
display: flex;
align-items: center;
justify-content: center;
@@ -154,6 +174,7 @@
.avatar-text {
color: #ffffff;
font-size: 26rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx原为36rpx差异17.6%*/
font-weight: 600;
}
@@ -168,7 +189,8 @@
.avatar--indigo { background: linear-gradient(135deg, #818cf8, #4f46e5); }
.card-name {
font-size: 28rpx;
font-size: 32rpx; /* P1-9: 修正 16px → 30rpx */
line-height: 44rpx; /* 高度修正:添加 line-height */
font-weight: 600;
color: #242424;
flex-shrink: 0;
@@ -184,6 +206,7 @@
.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;
@@ -197,7 +220,7 @@
.header-metrics {
display: flex;
align-items: center;
gap: 12rpx;
gap: 14rpx; /* P1-12: 修正 8px → 14rpx */
flex-shrink: 0;
}
@@ -214,21 +237,23 @@
}
.freq-big-val {
font-size: 36rpx;
font-size: 32rpx;
font-weight: 700;
color: #0052d9;
line-height: 1;
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: 20rpx;
font-size: 18rpx; /* H5 text-[10px]=10px → 18rpx */
line-height: 33rpx; /* 补充:约 1.8 倍 */
color: #a6a6a6;
margin-top: -2rpx;
}
@@ -248,17 +273,19 @@
font-size: 40rpx;
font-weight: 700;
color: #00a870;
line-height: 1;
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;
}
@@ -274,10 +301,11 @@
/* 超期标签 */
.overdue-tag {
padding: 4rpx 10rpx;
padding: 4rpx 12rpx; /* 补充修正6px → 12rpx */
font-size: 22rpx;
line-height: 29rpx; /* 补充text-xs line-height */
font-weight: 700;
border-radius: 6rpx;
border-radius: 8rpx;
}
.overdue-tag--danger {
@@ -292,9 +320,10 @@
/* 消费潜力标签 */
.potential-tag {
padding: 4rpx 10rpx;
padding: 4rpx 12rpx; /* 补充修正6px → 12rpx */
font-size: 22rpx;
border-radius: 6rpx;
line-height: 29rpx; /* 补充text-xs line-height */
border-radius: 8rpx;
}
.potential-tag--primary {
@@ -321,12 +350,14 @@
.card-mid-row {
display: flex;
align-items: center;
padding: 6rpx 0 4rpx 80rpx;
padding-left: 80rpx; /* 只保留左侧对齐 */
margin-bottom: 24rpx; /* 添加 mb-2 = 8px → 14rpx */
}
.mid-text {
font-size: 24rpx;
color: #c5c5c5;
font-size: 26rpx; /* P1-10: 修正 14px → 26rpx */
line-height: 36rpx; /* 新增 */
color: #a6a6a6;
}
.mid-dark {
@@ -345,9 +376,12 @@
}
.mid-ml {
margin-left: 20rpx;
margin-left: 30rpx; /* P0-5: 修正 16px → 30rpx */
}
.mid-ml-right {
margin-left: auto;
}
.mid-right {
margin-left: auto;
}
@@ -360,17 +394,19 @@
/* ===== 网格布局 ===== */
.card-grid {
display: grid;
gap: 12rpx;
padding: 6rpx 0 4rpx 80rpx;
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;
}
@@ -386,8 +422,9 @@
}
.grid-val {
font-size: 24rpx;
font-weight: 600;
font-size: 26rpx; /* 修正text-sm 14px → 26rpx */
line-height: 36rpx; /* text-sm line-height */
font-weight: 700;
color: #393939;
}
@@ -400,13 +437,15 @@
}
.grid-val--lg {
font-size: 28rpx;
font-size: 30rpx; /* 修正text-base 16px → 30rpx */
line-height: 44rpx; /* 补充text-base line-height */
font-weight: 700;
color: #242424;
}
/* ===== 迷你柱状图(最频繁维度) ===== */
.mini-chart {
padding: 8rpx 0 4rpx 80rpx;
padding: 0rpx 0 20rpx 80rpx; /* H5 mb-2=8px → 8×2×0.875≈14rpx */
}
.mini-chart-header {
@@ -417,14 +456,15 @@
.mini-chart-label {
font-size: 18rpx;
line-height: 33rpx; /* 新增 */
color: #c5c5c5;
}
.mini-chart-bars {
display: flex;
align-items: flex-end;
gap: 6rpx;
height: 48rpx;
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
height: 42rpx; /* H5 h-6=24px → 42rpx */
}
.mini-bar-col {
@@ -436,21 +476,22 @@
.mini-bar {
width: 100%;
background: rgba(0, 82, 217, 0.3);
border-radius: 4rpx 4rpx 0 0;
background: #0052d9;
border-radius: 8rpx 8rpx 0 0; /* 补充修正4px → 8rpx */
min-height: 4rpx;
}
.mini-chart-nums {
display: flex;
gap: 6rpx;
gap: 4rpx; /* H5 gap-0.5=2px → 4rpx */
margin-top: 4rpx;
}
.mini-chart-num {
flex: 1;
text-align: center;
font-size: 18rpx;
font-size: 16rpx; /* H5 text-[9px]=9px → 16rpx */
line-height: 29rpx; /* 新增 */
color: #c5c5c5;
}
@@ -465,14 +506,14 @@
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 10rpx;
margin-left: 80rpx;
padding-top: 10rpx;
border-top: 2rpx solid rgba(0, 0, 0, 0.04);
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;
}
@@ -490,6 +531,7 @@
.assistant-name {
font-size: 22rpx;
line-height: 29rpx; /* 新增 */
font-weight: 500;
}
@@ -499,7 +541,7 @@
}
.assistant-name.assistant--abandoned {
color: #a6a6a6;
color: #c5c5c5; /* H5 assistant-abandoned = gray-5 #c5c5c5 */
}
.assistant-name.assistant--normal {
@@ -508,8 +550,9 @@
.assistant-sep {
font-size: 20rpx;
line-height: 29rpx; /* 修复text-xs line-height 16px → 29rpx */
color: #c5c5c5;
margin: 0 6rpx;
margin: 0 12rpx; /* 补充修正6px → 12rpx */
}
/* 跟/弃 badge — 忠于 H5 原型:白字 + 渐变背景 + 阴影 */
@@ -527,62 +570,70 @@
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: 1rpx solid rgba(227, 77, 89, 0.28);
box-shadow: 0 2rpx 6rpx rgba(227, 77, 89, 0.28);
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: 1rpx solid rgba(120, 120, 120, 0.18);
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.14);
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 {
padding: 6rpx 0 4rpx 80rpx;
border-left: 4rpx solid #eeeeee;
margin-left: 80rpx;
padding-left: 14rpx;
margin-top: 4rpx;
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: 4rpx;
padding: 6rpx 0;
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: 8rpx;
padding-bottom: 10rpx; /* 间距由 margin-top 控制 */
}
.loyal-row--header .loyal-col {
font-size: 20rpx;
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: 24rpx;
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
}
.loyal-col--name {
width: 140rpx;
width: 168rpx;
flex: none;
text-align: left;
display: flex;
align-items: center;
gap: 4rpx;
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
}
.loyal-coach-name {
font-size: 22rpx;
font-size: 24rpx; /* H5 text-sm=14px → 24rpx数据行继承 */
line-height: 36rpx; /* 新增 */
font-weight: 500;
}
@@ -600,7 +651,9 @@
}
.loyal-val {
font-weight: 600;
font-size: 26rpx; /* 补充:继承 text-sm */
line-height: 36rpx; /* 已有 */
font-weight: 500; /* H5 font-medium=500 */
color: #393939;
}
@@ -615,17 +668,19 @@
/* 最专一头部右侧 */
.header-metrics--loyal {
gap: 6rpx;
gap: 8rpx; /* H5 gap-1=4px → 4×2×0.875≈8rpx */
}
.loyal-top-name {
font-size: 24rpx;
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
font-weight: 600;
color: #e34d59;
}
.loyal-top-score {
font-size: 24rpx;
font-size: 26rpx; /* 补充text-sm 14px → 26rpx */
line-height: 36rpx; /* 补充text-sm line-height */
font-weight: 700;
color: #0052d9;
}

View File

@@ -1,5 +1,7 @@
{
"navigationBarTitleText": "看板",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"metric-card": "/components/metric-card/metric-card",

View File

@@ -1,6 +1,8 @@
// 财务看板页 — 忠于 H5 原型结构,内联 mock 数据
// TODO: 联调时替换 mock 数据为真实 API 调用
import { getRandomAiColor } from '../../utils/ai-color'
/** 目录板块定义 */
interface TocItem {
emoji: string
@@ -62,10 +64,14 @@ const tipContents: Record<string, { title: string; content: string }> = {
Page({
data: {
pageState: 'normal' as 'loading' | 'empty' | 'normal',
pageState: 'normal' as 'loading' | 'empty' | 'error' | 'normal',
/** AI 配色 */
aiColorClass: '',
/** 时间筛选 */
selectedTime: 'month',
selectedTimeText: '本月',
timeOptions: [
{ value: 'month', text: '本月' },
{ value: 'lastMonth', text: '上月' },
@@ -79,6 +85,7 @@ Page({
/** 区域筛选 */
selectedArea: 'all',
selectedAreaText: '全部区域',
areaOptions: [
{ value: 'all', text: '全部区域' },
{ value: 'hall', text: '大厅' },
@@ -103,7 +110,12 @@ Page({
{ emoji: '🎱', title: '助教分析', sectionId: 'section-coach' },
] as TocItem[],
currentSectionIndex: 0,
scrollIntoView: '',
/** P1: 吸顶板块头H5: scaleX 从左滑入,同时筛选按钮 opacity 淡出) */
stickyHeaderVisible: false,
stickyHeaderEmoji: '',
stickyHeaderTitle: '',
stickyHeaderDesc: '',
/** 提示弹窗 */
tipVisible: false,
@@ -169,15 +181,15 @@ Page({
/** 应计收入确认 */
revenue: {
structureRows: [
{ name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
{ name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
{ name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
{ name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
{ name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
{ name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
{ name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
{ name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
{ name: '食品酒水', amount: '¥119,556', discount: '-¥68,136', booked: '¥51,420', bookedCompare: '6.5%' },
{ 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%' },
@@ -188,9 +200,10 @@ Page({
totalOccurrence: '¥823,456',
totalOccurrenceCompare: '12.5%',
discountItems: [
{ name: '会员折扣', value: '-¥45,200', compare: '3.1%' },
{ name: '赠送卡抵扣', value: '-¥42,016', compare: '2.5%' },
{ name: '团购差价', value: '-¥26,120', compare: '5.2%' },
{ 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%',
@@ -266,12 +279,120 @@ Page({
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() {
// mock 数据已内联,直接显示
// 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() {
@@ -290,12 +411,24 @@ Page({
/** 时间筛选变更 */
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedTime: e.detail.value })
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 }>) {
this.setData({ selectedArea: e.detail.value })
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)
},
/** 环比开关切换 */
@@ -312,7 +445,7 @@ Page({
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
@@ -320,8 +453,18 @@ Page({
this.setData({
tocVisible: false,
currentSectionIndex: index,
scrollIntoView: sectionId,
})
wx.createSelectorQuery().in(this)
.select(`#${sectionId}`)
.boundingClientRect((rect) => {
if (rect) {
wx.pageScrollTo({
scrollTop: rect.top + (this._lastScrollTop || 0) - 140,
duration: 300,
})
}
})
.exec()
}
},
@@ -342,4 +485,9 @@ Page({
closeTip() {
this.setData({ tipVisible: false })
},
/** P4: 错误态重试 */
onRetry() {
this.setData({ pageState: 'normal' })
},
})

View File

@@ -10,6 +10,14 @@
<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 导航 -->
@@ -26,15 +34,16 @@
</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">
<!-- 时间筛选(吸顶头显示时淡出) -->
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
<filter-dropdown
label="本月"
options="{{timeOptions}}"
@@ -43,8 +52,8 @@
/>
</view>
<!-- 区域筛选 -->
<view class="filter-item">
<!-- 区域筛选(吸顶头显示时淡出) -->
<view class="filter-item {{stickyHeaderVisible ? 'filter-item--hidden' : ''}}">
<filter-dropdown
label="全部区域"
options="{{areaOptions}}"
@@ -53,7 +62,7 @@
/>
</view>
<!-- 环比开关 -->
<!-- 环比开关(始终可见,不受吸顶头影响) -->
<view class="compare-switch" bindtap="toggleCompare">
<text class="compare-label">环比</text>
<view class="compare-toggle {{compareEnabled ? 'compare-toggle--active' : ''}}">
@@ -61,15 +70,23 @@
</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>
<!-- 滚动内容区 -->
<scroll-view
class="board-content"
scroll-y
scroll-into-view="{{scrollIntoView}}"
scroll-with-animation
>
<!-- 内容区(页面自然滚动) -->
<view class="board-content">
<!-- ===== 板块 1: 经营一览(深色) ===== -->
<view id="section-overview" class="card-section section-dark">
@@ -184,31 +201,52 @@
</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">🤖</view>
<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>
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text>充值高但消耗低,会员活跃度需提升</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">
<!-- ===== 板块 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>
<text class="card-header-desc-light">会员卡充值与余额 掌握资金沉淀 辅助会员运营策略定制</text>
</view>
</view>
<!-- 储值卡统计 -->
<view class="section-body">
<!-- 全类别会员卡余额合计 -->
<!-- CHANGE 2026-03-13 | intent: H5 中 label 和 help-icon 在同一行flex items-center mb-1MP 需要用 view 包裹实现同行布局 -->
<view class="total-balance-row">
<view class="total-balance-left">
<view class="total-balance-label">
<text>全类别会员卡余额合计</text>
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</view>
</view>
<text class="total-balance-note">仅经营参考,非财务属性</text>
</view>
<view class="total-balance-right">
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.allCardBalanceCompare}}</text>
</view>
</view>
</view>
<text class="card-section-title">储值卡统计</text>
<view class="table-bordered">
<!-- 行1储值卡充值实收 -->
@@ -273,7 +311,7 @@
</view>
<!-- 赠送卡统计详情 -->
<text class="card-section-title" style="margin-top: 28rpx;">赠送卡统计详情</text>
<text class="card-section-title" style="margin-top: 35rpx;">赠送卡统计详情</text>
<view class="table-bordered">
<!-- 表头 -->
<view class="gift-table-header">
@@ -282,51 +320,44 @@
<text class="gift-col">台费卡</text>
<text class="gift-col">抵用券</text>
</view>
<!-- 新增行 -->
<view class="gift-table-row" wx:for="{{recharge.giftRows}}" wx:key="label">
<!-- 数据行(新增/消费/余额) -->
<view class="gift-table-row {{compareEnabled ? 'gift-table-row--compare' : ''}}" wx:for="{{recharge.giftRows}}" wx:key="label">
<!-- 左列:标题 + 环比 / 总金额 -->
<view class="gift-col gift-col--name">
<text class="gift-row-label">{{item.label}}</text>
<text class="gift-row-total">{{item.total}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.totalCompare}}</text>
<view 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="compare-row" wx:if="{{compareEnabled}}">
<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="compare-row" wx:if="{{compareEnabled}}">
<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="compare-row" wx:if="{{compareEnabled}}">
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.couponCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 全类别会员卡余额合计 -->
<view class="total-balance-row">
<view class="total-balance-left">
<text class="total-balance-label">全类别会员卡余额合计</text>
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</view>
<text class="total-balance-note">仅经营参考,非财务属性</text>
</view>
<view class="total-balance-right">
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.allCardBalanceCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 3: 应计收入确认 ===== -->
@@ -354,7 +385,7 @@
<text class="rev-col">入账</text>
</view>
<!-- 数据行 -->
<block wx:for="{{revenue.structureRows}}" wx:key="name">
<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>
@@ -364,7 +395,7 @@
<text class="rev-col rev-val {{item.discount !== '-' ? 'rev-val--red' : 'rev-val--muted'}}">{{item.discount}}</text>
<view class="rev-col">
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{item.booked}}</text>
<view class="compare-row" wx:if="{{compareEnabled && item.bookedCompare}}">
<view class="compare-row-inline" wx:if="{{compareEnabled && item.bookedCompare}}">
<text class="compare-text-up-xs">↑{{item.bookedCompare}}</text>
</view>
</view>
@@ -373,7 +404,8 @@
</view>
<!-- 收入确认(损益链) -->
<view class="sub-title-row" style="margin-top: 28rpx;">
<!-- 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>
@@ -414,10 +446,13 @@
</view>
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.discountItems}}" wx:key="name">
<text class="flow-detail-name">{{item.name}}</text>
<view class="flow-detail-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 flow-detail-val--red">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<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>
@@ -426,8 +461,9 @@
<!-- 成交收入 -->
<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-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>
@@ -456,6 +492,8 @@
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 4: 现金流入 ===== -->
@@ -487,7 +525,8 @@
</view>
<!-- 充值收入 -->
<text class="flow-group-label" style="margin-top: 20rpx;">充值收入</text>
<!-- 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">
@@ -514,6 +553,8 @@
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 5: 现金流出 ===== -->
@@ -587,6 +628,8 @@
</view>
</view>
</view>
<!-- 底部锯齿(模拟 H5 card-section::after -->
<view class="card-tear"></view>
</view>
<!-- ===== 板块 6: 助教分析 ===== -->
@@ -595,7 +638,7 @@
<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>
<text class="card-header-desc-light">全部助教服务收入与分成的平均值,用以评估球房分成效益</text>
</view>
</view>
@@ -614,41 +657,41 @@
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalPay}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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" wx:for="{{coachAnalysis.basic.rows}}" wx:key="level">
<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" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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>
@@ -664,34 +707,36 @@
<text class="coach-fin-col">球房抽成</text>
<text class="coach-fin-col">小时平均</text>
</view>
<view class="coach-fin-row">
<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" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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" wx:if="{{compareEnabled}}">
<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>
</scroll-view>
</view>
<!-- ===== 目录导航遮罩 ===== -->
<view class="toc-overlay" wx:if="{{tocVisible}}" catchtap="closeToc"></view>
@@ -731,4 +776,4 @@
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" />
<dev-fab />
<dev-fab wx:if="{{false}}" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +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"
"t-empty": "tdesign-miniprogram/empty/empty",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -14,13 +14,17 @@ interface ChatHistoryDisplay {
Page({
data: {
/** 页面状态loading / empty / normal */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 页面状态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()
},
@@ -28,19 +32,33 @@ Page({
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const sorted = sortByTimestamp(mockChatHistory)
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
...item,
timeLabel: this.formatTime(item.timestamp),
}))
try {
setTimeout(() => {
// TODO: 替换为真实 API 调用
const sorted = sortByTimestamp(mockChatHistory)
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
...item,
timeLabel: this.formatTime(item.timestamp),
}))
this.setData({
list,
pageState: list.length === 0 ? 'empty' : 'normal',
})
}, 400)
this.setData({
list,
pageState: list.length === 0 ? 'empty' : 'normal',
})
}, 400)
} catch {
this.setData({ pageState: 'error' })
}
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
/** 重试加载 */
onRetry() {
this.loadData()
},
/** 格式化时间为相对标签 */

View File

@@ -1,19 +1,36 @@
<!-- pages/chat-history/chat-history.wxml — 对话历史 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</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" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无对话记录" />
<view class="page-empty-wrap" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty">
<t-empty description="暂无对话记录" />
</view>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<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}}"
@@ -42,7 +59,7 @@
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{120}}" />
</block>
<ai-float-button />
</view>
<dev-fab />

View File

@@ -1,3 +1,40 @@
/* 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 {
@@ -9,6 +46,56 @@
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;
@@ -19,11 +106,11 @@
align-items: center;
padding: 32rpx;
gap: 24rpx;
border-bottom: 1rpx solid var(--color-gray-1, #f3f3f3);
transition: background 0.2s ease;
border-bottom: 2rpx solid var(--color-gray-1, #f3f3f3);
}
.chat-item:active {
background: var(--color-gray-1, #f3f3f3);
.chat-item--hover {
background-color: var(--color-gray-1, #f3f3f3);
}
/* 图标容器 */
@@ -83,6 +170,7 @@
text-align: center;
padding: 32rpx 0 64rpx;
}
.footer-text {
font-size: 20rpx;
color: var(--color-gray-5, #c5c5c5);

View File

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

Some files were not shown because too many files have changed in this diff Show More