银行端口添加客户拜访功能

This commit is contained in:
2025-12-25 14:26:07 +08:00
parent 6bb0e00d69
commit 5312cfcb2e
16 changed files with 2541 additions and 12 deletions

View File

@@ -0,0 +1,21 @@
# Change: 添加银行端客户拜访计划功能
## Why
银行端客户经理需要记录和管理客户拜访活动,以便跟踪客户关系、记录营销产品推广情况,并保存现场拜访照片作为凭证。当前系统缺少拜访计划管理功能,无法满足客户关系管理的业务需求。
## What Changes
- 在银行端添加客户拜访计划创建页面
- 支持设置拜访日期、选择客户、定位位置、选择营销产品、填写拜访主题和备注
- 支持上传拜访场景照片(拍照或从相册选择)
- 添加拜访计划保存功能
- 添加拜访计划列表和详情查看功能
## Impact
- Affected specs: `bank-visit-plan` (新能力)
- Affected code:
- `src/pagesBank/` - 新增拜访计划相关页面
- `src/pagesBank/api/` - 新增拜访计划 API 接口
- `src/typings/bank.ts` - 新增拜访计划类型定义

View File

@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: 创建拜访计划
银行端用户 SHALL 能够创建客户拜访计划,记录拜访的详细信息。
#### Scenario: 成功创建拜访计划
- **WHEN** 用户填写完整的拜访计划信息(日期、客户、位置、拜访主题、照片)
- **THEN** 系统 SHALL 保存拜访计划并返回成功提示
#### Scenario: 创建拜访计划时验证必填字段
- **WHEN** 用户提交拜访计划时缺少必填字段(日期、客户、位置、拜访主题、照片)
- **THEN** 系统 SHALL 显示相应的错误提示,阻止提交
#### Scenario: 选择营销产品
- **WHEN** 用户点击营销产品选择器
- **THEN** 系统 SHALL 显示可选产品列表,支持多选
#### Scenario: 上传拜访场景图
- **WHEN** 用户点击上传按钮
- **THEN** 系统 SHALL 提供拍照和从相册选择两种方式
### Requirement: 拜访计划列表
银行端用户 SHALL 能够查看拜访计划列表,支持筛选和搜索。
#### Scenario: 查看拜访计划列表
- **WHEN** 用户进入拜访计划列表页面
- **THEN** 系统 SHALL 显示所有拜访计划,按日期倒序排列
#### Scenario: 按状态筛选拜访计划
- **WHEN** 用户选择状态筛选条件(待拜访、已完成、已取消)
- **THEN** 系统 SHALL 显示对应状态的拜访计划
#### Scenario: 搜索拜访计划
- **WHEN** 用户输入关键词搜索
- **THEN** 系统 SHALL 显示匹配的拜访计划(按客户名称或拜访主题)
### Requirement: 拜访计划详情
银行端用户 SHALL 能够查看拜访计划的详细信息。
#### Scenario: 查看拜访计划详情
- **WHEN** 用户点击拜访计划列表项
- **THEN** 系统 SHALL 显示拜访计划的完整信息(日期、客户、位置、营销产品、拜访主题、备注、照片)
#### Scenario: 更新拜访状态
- **WHEN** 用户在详情页更新拜访状态
- **THEN** 系统 SHALL 保存状态变更并刷新页面
#### Scenario: 编辑拜访计划
- **WHEN** 用户点击编辑按钮
- **THEN** 系统 SHALL 进入编辑模式,允许修改拜访计划信息
### Requirement: 客户选择器
系统 SHALL 提供客户选择器,支持从客户列表中选择目标客户。
#### Scenario: 打开客户选择器
- **WHEN** 用户点击客户选择字段
- **THEN** 系统 SHALL 弹出客户列表页面
#### Scenario: 选择客户
- **WHEN** 用户从列表中选择一个客户
- **THEN** 系统 SHALL 返回并显示选中的客户信息
### Requirement: 位置定位
系统 SHALL 支持获取当前位置或手动输入地址。
#### Scenario: 自动定位
- **WHEN** 用户点击定位按钮
- **THEN** 系统 SHALL 获取当前位置并显示地址信息
#### Scenario: 手动输入地址
- **WHEN** 用户手动输入地址
- **THEN** 系统 SHALL 保存用户输入的地址信息

View File

@@ -0,0 +1,42 @@
# Implementation Tasks
## 1. 类型定义和 API 接口
- [ ] 1.1 在 `src/typings/bank.ts` 中添加拜访计划相关类型定义
- [ ] 1.2 在 `src/pagesBank/api/index.ts` 中添加拜访计划 API 接口
- [ ] 1.3 在 `src/pagesBank/mock/index.ts` 中添加拜访计划 Mock 数据
## 2. 拜访计划创建页面
- [ ] 2.1 创建 `src/pagesBank/visit/create.vue` 拜访计划创建页面
- [ ] 2.2 实现日期选择器组件
- [ ] 2.3 实现客户选择器(弹出客户列表)
- [ ] 2.4 实现位置定位功能(地图定位/文本输入)
- [ ] 2.5 实现营销产品多选功能
- [ ] 2.6 实现拜访主题和备注输入
- [ ] 2.7 实现图片上传功能(拍照/相册选择)
- [ ] 2.8 实现表单验证和提交功能
## 3. 拜访计划列表页面
- [ ] 3.1 创建 `src/pagesBank/visit/list.vue` 拜访计划列表页面
- [ ] 3.2 实现拜访计划列表展示
- [ ] 3.3 实现状态筛选(待拜访、已完成、已取消)
- [ ] 3.4 实现搜索功能
## 4. 拜访计划详情页面
- [ ] 4.1 创建 `src/pagesBank/visit/detail.vue` 拜访计划详情页面
- [ ] 4.2 实现拜访计划详情展示
- [ ] 4.3 实现拜访状态更新功能
- [ ] 4.4 实现拜访记录编辑功能
## 5. 路由配置
- [ ] 5.1 在 `src/pages.json` 中添加拜访计划相关路由配置
## 6. 导航入口
- [ ] 6.1 在银行端首页或客户详情页添加"创建拜访计划"入口
- [ ] 6.2 在客户详情页添加"拜访记录"入口
## 7. 测试和验证
- [ ] 7.1 测试拜访计划创建流程
- [ ] 7.2 测试拜访计划列表展示和筛选
- [ ] 7.3 测试拜访计划详情查看和编辑
- [ ] 7.4 测试图片上传功能
- [ ] 7.5 测试表单验证

View File

@@ -0,0 +1,29 @@
# Change: 修改银行端客户拜访计划功能
## Why
当前拜访计划功能在创建时要求填写位置和上传拜访场景图,但在实际业务场景中,这些信息应该在拜访完成时才需要填写。创建拜访计划时只需要记录基本的拜访安排(日期、客户、主题等),而在标记拜访完成时才需要补充位置信息和上传现场照片。这样的流程更符合实际业务需求。
## What Changes
- **创建拜访计划页面** (`/pagesBank/visit/create`) 修改:
- 移除位置输入和定位功能
- 移除上传拜访场景图功能
- 保留日期、客户选择、营销产品、拜访主题、备注字段
- 更新表单验证逻辑,不再验证位置和照片
- **拜访计划详情页面** (`/pagesBank/visit/detail?id={}`) 修改:
- 在标记完成时,要求填写位置信息(模拟从地图获取地址,默认使用"广东省茂名市"
- 在标记完成时,要求上传拜访场景图
- 添加位置和照片的必填验证
- 更新状态更新流程,先验证位置和照片后再更新状态
## Impact
- Affected specs: `bank-visit-plan` (修改)
- Affected code:
- `src/pagesBank/visit/create.vue` - 移除位置和照片相关代码
- `src/pagesBank/visit/detail.vue` - 添加完成时的位置和照片填写功能
- `src/pagesBank/api/index.ts` - 更新 `updateVisitStatus` API 支持位置和照片参数
- `src/typings/bank.ts` - 更新 `CreateVisitPlanParams` 类型定义
- `openspec/specs/bank-visit-plan/spec.md` - 更新需求规格说明

View File

@@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: 创建拜访计划
银行端用户 SHALL 能够创建客户拜访计划,记录拜访的基本信息。
#### Scenario: 成功创建拜访计划
- **WHEN** 用户填写完整的拜访计划信息(日期、客户、拜访主题)
- **THEN** 系统 SHALL 保存拜访计划并返回成功提示
#### Scenario: 创建拜访计划时验证必填字段
- **WHEN** 用户提交拜访计划时缺少必填字段(日期、客户、拜访主题)
- **THEN** 系统 SHALL 显示相应的错误提示,阻止提交
#### Scenario: 选择营销产品
- **WHEN** 用户点击营销产品选择器
- **THEN** 系统 SHALL 显示可选产品列表,支持多选
### Requirement: 拜访计划列表
银行端用户 SHALL 能够查看拜访计划列表,支持筛选和搜索。
#### Scenario: 查看拜访计划列表
- **WHEN** 用户进入拜访计划列表页面
- **THEN** 系统 SHALL 显示所有拜访计划,按日期倒序排列
#### Scenario: 按状态筛选拜访计划
- **WHEN** 用户选择状态筛选条件(待拜访、已完成、已取消)
- **THEN** 系统 SHALL 显示对应状态的拜访计划
#### Scenario: 搜索拜访计划
- **WHEN** 用户输入关键词搜索
- **THEN** 系统 SHALL 显示匹配的拜访计划(按客户名称或拜访主题)
### Requirement: 拜访计划详情
银行端用户 SHALL 能够查看拜访计划的详细信息。
#### Scenario: 查看拜访计划详情
- **WHEN** 用户点击拜访计划列表项
- **THEN** 系统 SHALL 显示拜访计划的完整信息(日期、客户、营销产品、拜访主题、备注)
#### Scenario: 标记拜访完成
- **WHEN** 用户点击"标记完成"按钮
- **THEN** 系统 SHALL 弹出填写位置和上传照片的表单
- **AND** 用户填写位置信息(支持自动定位或手动输入,默认地址为"广东省茂名市"
- **AND** 用户上传至少一张拜访场景图
- **AND** 系统 SHALL 验证位置和照片必填
- **AND** 验证通过后更新拜访状态为"已完成"
#### Scenario: 标记完成时验证必填字段
- **WHEN** 用户提交完成标记时缺少位置信息或照片
- **THEN** 系统 SHALL 显示相应的错误提示,阻止状态更新
#### Scenario: 编辑拜访计划
- **WHEN** 用户点击编辑按钮
- **THEN** 系统 SHALL 进入编辑模式,允许修改拜访计划信息(日期、主题、备注)
### Requirement: 客户选择器
系统 SHALL 提供客户选择器,支持从客户列表中选择目标客户。
#### Scenario: 打开客户选择器
- **WHEN** 用户点击客户选择字段
- **THEN** 系统 SHALL 弹出客户列表页面
#### Scenario: 选择客户
- **WHEN** 用户从列表中选择一个客户
- **THEN** 系统 SHALL 返回并显示选中的客户信息
### Requirement: 位置定位
系统 SHALL 支持获取当前位置或手动输入地址。
#### Scenario: 自动定位
- **WHEN** 用户点击定位按钮
- **THEN** 系统 SHALL 获取当前位置并显示地址信息
#### Scenario: 手动输入地址
- **WHEN** 用户手动输入地址
- **THEN** 系统 SHALL 保存用户输入的地址信息
#### Scenario: 使用默认地址
- **WHEN** 用户未填写位置信息
- **THEN** 系统 SHALL 使用默认地址"广东省茂名市"
### Requirement: 上传拜访场景图
系统 SHALL 支持上传拜访场景照片。
#### Scenario: 上传拜访场景图
- **WHEN** 用户点击上传按钮
- **THEN** 系统 SHALL 提供拍照和从相册选择两种方式
#### Scenario: 验证照片数量
- **WHEN** 用户标记拜访完成时
- **THEN** 系统 SHALL 要求至少上传一张拜访场景图

View File

@@ -0,0 +1,67 @@
# Tasks: 修改银行端客户拜访计划功能
## 任务列表
### 1. 修改类型定义
- [x] 更新 `src/typings/bank.ts` 中的 `CreateVisitPlanParams` 接口
- 移除 `location``latitude``longitude``photos` 字段
- 这些字段将在标记完成时通过 `updateVisitStatus` API 提供
- 新增 `CompleteVisitPlanParams` 接口用于标记完成时的参数
### 2. 修改创建拜访计划页面
- [x] 修改 `src/pagesBank/visit/create.vue`
- 移除位置输入和定位功能
- 移除上传拜访场景图功能
- 更新表单验证逻辑
- 移除位置验证
- 移除照片验证
- 更新提交表单逻辑
- 移除位置和照片参数
### 3. 修改拜访计划详情页面
- [x] 修改 `src/pagesBank/visit/detail.vue`
- 添加标记完成时的表单状态管理
- 添加位置输入和定位功能
- 添加上传拜访场景图功能
- 修改 `handleUpdateStatus` 函数
- 点击"标记完成"时弹出填写位置和上传照片的表单
- 验证位置和照片必填
- 验证通过后调用更新状态 API
- 更新模板部分,添加位置和照片填写表单
### 4. 修改 API 接口
- [x] 修改 `src/pagesBank/api/index.ts`
- 更新 `createVisitPlan` 函数
- 移除位置和照片参数处理
- 创建时位置和照片为空
- 更新 `updateVisitStatus` 函数
- 添加 `location``latitude``longitude``photos` 参数
- 更新拜访计划时保存位置和照片信息
- 更新 `updateVisitPlan` 函数
- 移除位置和照片相关字段的处理
### 5. 更新 Mock 数据
- [x] 修改 `src/pagesBank/mock/index.ts`
- 更新 `mockVisitPlans` 数据结构
- 确保待拜访的拜访计划位置和照片为空
- 已完成的拜访计划包含位置和照片信息
### 6. 更新规格说明文档
- [x] 更新 `openspec/specs/bank-visit-plan/spec.md`
- 应用修改提案中的规格变更
- 更新创建拜访计划的需求
- 更新拜访计划详情的需求
- 添加标记完成时的场景
### 7. 测试验证
- [x] 测试创建拜访计划功能
- 验证不填写位置和照片可以成功创建
- 验证必填字段验证正常工作
- [x] 测试标记拜访完成功能
- 验证必须填写位置和上传照片才能标记完成
- 验证自动定位功能正常
- 验证默认地址"广东省茂名市"正常使用
- 验证照片上传功能正常
- [x] 测试拜访计划详情页面
- 验证位置和照片信息正确显示
- 验证编辑功能正常工作

View File

@@ -0,0 +1,95 @@
# bank-visit-plan Specification
## Purpose
TBD - created by archiving change add-bank-visit-plan. Update Purpose after archive.
## Requirements
### Requirement: 创建拜访计划
银行端用户 SHALL 能够创建客户拜访计划,记录拜访的基本信息。
#### Scenario: 成功创建拜访计划
- **WHEN** 用户填写完整的拜访计划信息(日期、客户、拜访主题)
- **THEN** 系统 SHALL 保存拜访计划并返回成功提示
#### Scenario: 创建拜访计划时验证必填字段
- **WHEN** 用户提交拜访计划时缺少必填字段(日期、客户、拜访主题)
- **THEN** 系统 SHALL 显示相应的错误提示,阻止提交
#### Scenario: 选择营销产品
- **WHEN** 用户点击营销产品选择器
- **THEN** 系统 SHALL 显示可选产品列表,支持多选
### Requirement: 拜访计划列表
银行端用户 SHALL 能够查看拜访计划列表,支持筛选和搜索。
#### Scenario: 查看拜访计划列表
- **WHEN** 用户进入拜访计划列表页面
- **THEN** 系统 SHALL 显示所有拜访计划,按日期倒序排列
#### Scenario: 按状态筛选拜访计划
- **WHEN** 用户选择状态筛选条件(待拜访、已完成、已取消)
- **THEN** 系统 SHALL 显示对应状态的拜访计划
#### Scenario: 搜索拜访计划
- **WHEN** 用户输入关键词搜索
- **THEN** 系统 SHALL 显示匹配的拜访计划(按客户名称或拜访主题)
### Requirement: 拜访计划详情
银行端用户 SHALL 能够查看拜访计划的详细信息。
#### Scenario: 查看拜访计划详情
- **WHEN** 用户点击拜访计划列表项
- **THEN** 系统 SHALL 显示拜访计划的完整信息(日期、客户、营销产品、拜访主题、备注)
#### Scenario: 标记拜访完成
- **WHEN** 用户点击"标记完成"按钮
- **THEN** 系统 SHALL 弹出填写位置和上传照片的表单
- **AND** 用户填写位置信息(支持自动定位或手动输入,默认地址为"广东省茂名市"
- **AND** 用户上传至少一张拜访场景图
- **AND** 系统 SHALL 验证位置和照片必填
- **AND** 验证通过后更新拜访状态为"已完成"
#### Scenario: 标记完成时验证必填字段
- **WHEN** 用户提交完成标记时缺少位置信息或照片
- **THEN** 系统 SHALL 显示相应的错误提示,阻止状态更新
#### Scenario: 编辑拜访计划
- **WHEN** 用户点击编辑按钮
- **THEN** 系统 SHALL 进入编辑模式,允许修改拜访计划信息(日期、主题、备注)
### Requirement: 客户选择器
系统 SHALL 提供客户选择器,支持从客户列表中选择目标客户。
#### Scenario: 打开客户选择器
- **WHEN** 用户点击客户选择字段
- **THEN** 系统 SHALL 弹出客户列表页面
#### Scenario: 选择客户
- **WHEN** 用户从列表中选择一个客户
- **THEN** 系统 SHALL 返回并显示选中的客户信息
### Requirement: 位置定位
系统 SHALL 支持获取当前位置或手动输入地址。
#### Scenario: 自动定位
- **WHEN** 用户点击定位按钮
- **THEN** 系统 SHALL 获取当前位置并显示地址信息
#### Scenario: 手动输入地址
- **WHEN** 用户手动输入地址
- **THEN** 系统 SHALL 保存用户输入的地址信息
#### Scenario: 使用默认地址
- **WHEN** 用户未填写位置信息
- **THEN** 系统 SHALL 使用默认地址"广东省茂名市"
### Requirement: 上传拜访场景图
系统 SHALL 支持上传拜访场景照片。
#### Scenario: 上传拜访场景图
- **WHEN** 用户点击上传按钮
- **THEN** 系统 SHALL 提供拍照和从相册选择两种方式
#### Scenario: 验证照片数量
- **WHEN** 用户标记拜访完成时
- **THEN** 系统 SHALL 要求至少上传一张拜访场景图

View File

@@ -246,6 +246,30 @@
"navigationBarTitleText": "客户管理"
}
},
{
"path": "customer/detail",
"style": {
"navigationBarTitleText": "客户详情"
}
},
{
"path": "visit/list",
"style": {
"navigationBarTitleText": "拜访计划"
}
},
{
"path": "visit/create",
"style": {
"navigationBarTitleText": "创建拜访计划"
}
},
{
"path": "visit/detail",
"style": {
"navigationBarTitleText": "拜访计划详情"
}
},
{
"path": "me/index",
"style": {

View File

@@ -1,15 +1,21 @@
import type {
BankStats,
AuditItem,
AuditStatus,
import type {
BankStats,
AuditItem,
AuditStatus,
BankCustomer,
WithdrawAuditDetail
WithdrawAuditDetail,
VisitPlan,
CreateVisitPlanParams,
MarketingProduct
} from '@/typings/bank'
import { VisitStatus } from '@/typings/bank'
import {
mockBankStats,
mockAuditList,
getMockWithdrawDetail,
mockCustomerList
mockCustomerList,
mockVisitPlans,
mockMarketingProducts
} from '../mock'
/** 获取银行端首页统计 */
@@ -173,3 +179,150 @@ export function getCustomerWithdraws(params: {
}, 500)
})
}
/** 获取营销产品列表 */
export function getMarketingProducts(): Promise<MarketingProduct[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(mockMarketingProducts)
}, 300)
})
}
/** 获取拜访计划列表 */
export function getVisitPlanList(params: {
status?: string
pageNum: number
pageSize: number
keyword?: string
}): Promise<{ list: VisitPlan[]; total: number }> {
return new Promise((resolve) => {
setTimeout(() => {
let list = [...mockVisitPlans]
// 状态筛选
if (params.status) {
list = list.filter(item => item.status === params.status)
}
// 关键词筛选
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
list = list.filter(item =>
item.customerName.toLowerCase().includes(keyword) ||
item.topic.toLowerCase().includes(keyword)
)
}
// 按日期倒序
list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
resolve({ list, total: list.length })
}, 500)
})
}
/** 获取拜访计划详情 */
export function getVisitPlanDetail(id: string): Promise<VisitPlan | null> {
return new Promise((resolve) => {
setTimeout(() => {
const plan = mockVisitPlans.find(item => item.id === id)
resolve(plan || null)
}, 300)
})
}
/** 创建拜访计划 */
export function createVisitPlan(data: CreateVisitPlanParams): Promise<VisitPlan> {
return new Promise((resolve) => {
setTimeout(() => {
const customer = mockCustomerList.find(c => c.id === data.customerId)
const products = mockMarketingProducts.filter(p => data.productIds.includes(p.id))
const newPlan: VisitPlan = {
id: `V${Date.now()}`,
customerId: data.customerId,
customerName: customer?.merchantName || '',
date: data.date,
location: '',
latitude: undefined,
longitude: undefined,
products,
topic: data.topic,
remark: data.remark,
photos: [],
status: VisitStatus.PENDING,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
mockVisitPlans.unshift(newPlan)
resolve(newPlan)
}, 500)
})
}
/** 更新拜访计划 */
export function updateVisitPlan(id: string, data: Partial<CreateVisitPlanParams>): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => {
const plan = mockVisitPlans.find(item => item.id === id)
if (plan) {
if (data.customerId) {
const customer = mockCustomerList.find(c => c.id === data.customerId)
plan.customerId = data.customerId
plan.customerName = customer?.merchantName || ''
}
if (data.date) plan.date = data.date
if (data.productIds) {
plan.products = mockMarketingProducts.filter(p => data.productIds!.includes(p.id))
}
if (data.topic) plan.topic = data.topic
if (data.remark !== undefined) plan.remark = data.remark
plan.updatedAt = new Date().toISOString()
}
resolve(true)
}, 500)
})
}
/** 更新拜访状态 */
export function updateVisitStatus(
id: string,
status: VisitStatus,
location?: string,
latitude?: number,
longitude?: number,
photos?: string[]
): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => {
const plan = mockVisitPlans.find(item => item.id === id)
if (plan) {
plan.status = status
plan.updatedAt = new Date().toISOString()
// 标记完成时更新位置和照片
if (status === VisitStatus.COMPLETED) {
if (location) plan.location = location
if (latitude !== undefined) plan.latitude = latitude
if (longitude !== undefined) plan.longitude = longitude
if (photos) plan.photos = photos
}
}
resolve(true)
}, 300)
})
}
/** 删除拜访计划 */
export function deleteVisitPlan(id: string): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => {
const index = mockVisitPlans.findIndex(item => item.id === id)
if (index > -1) {
mockVisitPlans.splice(index, 1)
}
resolve(true)
}, 300)
})
}

View File

@@ -92,6 +92,18 @@ function handleFreeze() {
})
}
function handleCreateVisit() {
uni.navigateTo({
url: `/pagesBank/visit/create?customerId=${id.value}`
})
}
function handleVisitRecords() {
uni.navigateTo({
url: `/pagesBank/visit/list?customerId=${id.value}`
})
}
onLoad((options) => {
if (options?.id) {
id.value = options.id
@@ -189,6 +201,14 @@ onLoad((options) => {
<text class="i-carbon-wallet"></text>
提现记录
</view>
<view class="action-item" @click="handleCreateVisit">
<text class="i-carbon-calendar-add"></text>
创建拜访
</view>
<view class="action-item" @click="handleVisitRecords">
<text class="i-carbon-calendar"></text>
拜访记录
</view>
<view class="action-item" :class="{ danger: detail.status !== 'frozen' }" @click="handleFreeze">
<text :class="detail.status === 'frozen' ? 'i-carbon-locked' : 'i-carbon-unlocked'"></text>
{{ detail.status === 'frozen' ? '解冻账户' : '冻结账户' }}

View File

@@ -15,7 +15,8 @@ const bankStore = useBankStore()
const quickActions = [
{ icon: 'i-carbon-task-approved', label: '待审核', path: '/pagesBank/audit/list' },
{ icon: 'i-carbon-group', label: '客户管理', path: '/pagesBank/customer/list' },
{ icon: 'i-carbon-report', label: '数据报表', path: '' }, // 暂未实现
{ icon: 'i-carbon-calendar', label: '拜访计划', path: '/pagesBank/visit/list' },
{ icon: 'i-carbon-add', label: '创建拜访', path: '/pagesBank/visit/create' },
{ icon: 'i-carbon-settings', label: '设置', path: '/pagesBank/me/index' },
]

View File

@@ -1,10 +1,12 @@
import type {
BankStats,
AuditItem,
import type {
BankStats,
AuditItem,
BankCustomer,
WithdrawAuditDetail
WithdrawAuditDetail,
VisitPlan,
MarketingProduct
} from '@/typings/bank'
import { AuditStatus, AuditType } from '@/typings/bank'
import { AuditStatus, AuditType, VisitStatus } from '@/typings/bank'
// 统计数据 Mock
export const mockBankStats: BankStats = {
@@ -109,3 +111,69 @@ export const mockWithdrawHistory = [
{ id: 'W005', amount: 10000.00, status: AuditStatus.APPROVED, time: '2024-12-01 09:00', bank: '工商银行(8888)' },
{ id: 'W008', amount: 2000.00, status: AuditStatus.REJECTED, time: '2024-11-20 16:20', bank: '工商银行(8888)', reason: '账户信息有误' },
]
// 营销产品 Mock
export const mockMarketingProducts: MarketingProduct[] = [
{ id: 'P001', name: '公司理财', type: 'credit' },
{ id: 'P002', name: '公司贷款', type: 'loan' },
{ id: 'P003', name: '信用卡', type: 'credit' },
{ id: 'P004', name: '结算服务', type: 'settlement' },
{ id: 'P005', name: '企业网银', type: 'other' },
]
// 拜访计划 Mock
export const mockVisitPlans: VisitPlan[] = [
{
id: 'V001',
customerId: 'C1001',
customerName: '张三',
date: '2025-12-26',
location: '',
latitude: undefined,
longitude: undefined,
products: [
{ id: 'P001', name: '公司理财', type: 'credit' },
{ id: 'P002', name: '公司贷款', type: 'loan' }
],
topic: '推广公司理财和贷款产品',
remark: '客户对理财产品感兴趣,需要详细介绍',
photos: [],
status: VisitStatus.PENDING,
createdAt: '2025-12-25 10:00:00',
updatedAt: '2025-12-25 10:00:00'
},
{
id: 'V002',
customerId: 'C1002',
customerName: '李四',
date: '2025-12-24',
location: '广东省茂名市',
latitude: 21.6630,
longitude: 110.9250,
products: [
{ id: 'P003', name: '信用卡', type: 'credit' }
],
topic: '信用卡业务推广',
remark: '客户已有信用卡,考虑升级',
photos: ['/static/images/visit2.jpg', '/static/images/visit3.jpg'],
status: VisitStatus.COMPLETED,
createdAt: '2025-12-23 15:30:00',
updatedAt: '2025-12-24 18:00:00'
},
{
id: 'V003',
customerId: 'C1001',
customerName: '张三',
date: '2025-12-20',
location: '',
latitude: undefined,
longitude: undefined,
products: [],
topic: '客户回访',
remark: '因客户临时有事,拜访取消',
photos: [],
status: VisitStatus.CANCELLED,
createdAt: '2025-12-19 09:00:00',
updatedAt: '2025-12-20 10:00:00'
}
]

View File

@@ -0,0 +1,425 @@
<script lang="ts" setup>
import { getCustomerList, getMarketingProducts, createVisitPlan } from '@/pagesBank/api'
import type { BankCustomer, MarketingProduct } from '@/typings/bank'
definePage({
style: {
navigationBarTitleText: '创建拜访计划',
},
})
// 表单数据
const formData = ref({
date: '',
customerId: '',
customerName: '',
productIds: [] as string[],
topic: '',
remark: ''
})
// 客户列表
const customers = ref<BankCustomer[]>([])
const showCustomerPicker = ref(false)
// 营销产品列表
const products = ref<MarketingProduct[]>([])
const showProductPicker = ref(false)
// 加载状态
const loading = ref(false)
const submitting = ref(false)
// 加载客户列表
async function loadCustomers() {
try {
const res = await getCustomerList({
pageNum: 1,
pageSize: 100
})
customers.value = res.list
} catch (error) {
uni.showToast({ title: '加载客户列表失败', icon: 'none' })
}
}
// 加载营销产品列表
async function loadProducts() {
try {
products.value = await getMarketingProducts()
} catch (error) {
uni.showToast({ title: '加载产品列表失败', icon: 'none' })
}
}
// 选择客户
function handleSelectCustomer(customer: BankCustomer) {
formData.value.customerId = customer.id
formData.value.customerName = customer.merchantName
showCustomerPicker.value = false
}
// 选择营销产品
function toggleProduct(productId: string) {
const index = formData.value.productIds.indexOf(productId)
if (index > -1) {
formData.value.productIds.splice(index, 1)
} else {
formData.value.productIds.push(productId)
}
}
// 表单验证
function validateForm(): boolean {
if (!formData.value.date) {
uni.showToast({ title: '请选择拜访日期', icon: 'none' })
return false
}
if (!formData.value.customerId) {
uni.showToast({ title: '请选择客户', icon: 'none' })
return false
}
if (!formData.value.topic) {
uni.showToast({ title: '请输入拜访主题', icon: 'none' })
return false
}
return true
}
// 提交表单
async function handleSubmit() {
if (!validateForm()) return
submitting.value = true
try {
await createVisitPlan({
customerId: formData.value.customerId,
date: formData.value.date,
productIds: formData.value.productIds,
topic: formData.value.topic,
remark: formData.value.remark || undefined
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.showToast({ title: '创建失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
// 获取选中的产品名称
function getSelectedProductNames(): string {
const selected = products.value.filter(p => formData.value.productIds.includes(p.id))
return selected.map(p => p.name).join('、')
}
onLoad((options) => {
if (options?.customerId) {
formData.value.customerId = options.customerId
}
})
onMounted(() => {
loadCustomers()
loadProducts()
})
</script>
<template>
<view class="visit-create-page">
<view class="form-container">
<!-- 日期选择 -->
<view class="form-item">
<view class="label required">日期</view>
<wd-datetime-picker
v-model="formData.date"
type="date"
:min-date="Date.now()"
placeholder="请选择拜访日期"
/>
</view>
<!-- 客户选择 -->
<view class="form-item" @click="showCustomerPicker = true">
<view class="label required">选择客户</view>
<view class="value-input">
<text :class="{ placeholder: !formData.customerName }">
{{ formData.customerName || '请选择' }}
</text>
<text class="i-carbon-chevron-right"></text>
</view>
</view>
<!-- 营销产品 -->
<view class="form-item" @click="showProductPicker = true">
<view class="label">营销产品</view>
<view class="value-input">
<text :class="{ placeholder: formData.productIds.length === 0 }">
{{ formData.productIds.length > 0 ? `已选${formData.productIds.length}` : '请选择' }}
</text>
<text class="i-carbon-chevron-right"></text>
</view>
</view>
<!-- 拜访主题 -->
<view class="form-item">
<view class="label required">拜访主题</view>
<input
v-model="formData.topic"
placeholder="主题、议题或活动等"
class="input"
/>
</view>
<!-- 备注 -->
<view class="form-item">
<view class="label">备注</view>
<textarea
v-model="formData.remark"
placeholder="本次拜访的备注内容"
class="textarea"
:maxlength="500"
/>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn" :disabled="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : '保存拜访计划' }}
</button>
</view>
<!-- 客户选择弹窗 -->
<wd-popup v-model="showCustomerPicker" position="bottom" :close-on-click-modal="false">
<view class="customer-picker">
<view class="picker-header">
<text class="cancel" @click="showCustomerPicker = false">取消</text>
<text class="title">选择客户</text>
<text class="confirm" @click="showCustomerPicker = false">确定</text>
</view>
<scroll-view scroll-y class="customer-list">
<view
v-for="customer in customers"
:key="customer.id"
class="customer-item"
:class="{ active: formData.customerId === customer.id }"
@click="handleSelectCustomer(customer)"
>
<view class="customer-info">
<text class="name">{{ customer.merchantName }}</text>
<text class="contact">{{ customer.contactName }} {{ customer.contactPhone }}</text>
</view>
<text v-if="formData.customerId === customer.id" class="i-carbon-checkmark-filled"></text>
</view>
</scroll-view>
</view>
</wd-popup>
<!-- 产品选择弹窗 -->
<wd-popup v-model="showProductPicker" position="bottom" :close-on-click-modal="false">
<view class="product-picker">
<view class="picker-header">
<text class="cancel" @click="showProductPicker = false">取消</text>
<text class="title">选择营销产品</text>
<text class="confirm" @click="showProductPicker = false">确定</text>
</view>
<scroll-view scroll-y class="product-list">
<view
v-for="product in products"
:key="product.id"
class="product-item"
:class="{ active: formData.productIds.includes(product.id) }"
@click="toggleProduct(product.id)"
>
<text class="name">{{ product.name }}</text>
<text v-if="formData.productIds.includes(product.id)" class="i-carbon-checkmark-filled"></text>
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</template>
<style lang="scss" scoped>
.visit-create-page {
min-height: 100vh;
background: #f8f9fa;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.form-container {
padding: 24rpx 30rpx;
}
.form-item {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 30rpx;
margin-bottom: 24rpx;
.label {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 20rpx;
display: block;
&.required::before {
content: '*';
color: #fa4350;
margin-right: 4rpx;
}
}
.value-input {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 28rpx;
color: #333;
.placeholder {
color: #adb5bd;
}
text:last-child {
color: #adb5bd;
font-size: 32rpx;
}
}
.input {
width: 100%;
font-size: 28rpx;
color: #333;
}
.textarea {
width: 100%;
height: 200rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 30rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.submit-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(135deg, #00c05a 0%, #34d19d 100%);
border-radius: 44rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
border: none;
&[disabled] {
opacity: 0.6;
}
}
}
.customer-picker,
.product-picker {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 70vh;
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f1f3f5;
.cancel,
.confirm {
font-size: 28rpx;
color: #4d80f0;
padding: 8rpx 16rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
.customer-list,
.product-list {
max-height: 60vh;
}
}
.customer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f8f9fa;
&.active {
background: rgba(0, 192, 90, 0.05);
}
.customer-info {
flex: 1;
.name {
font-size: 28rpx;
color: #333;
font-weight: 500;
display: block;
margin-bottom: 8rpx;
}
.contact {
font-size: 24rpx;
color: #999;
}
}
text:last-child {
font-size: 40rpx;
color: #00c05a;
}
}
.product-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f8f9fa;
&.active {
background: rgba(0, 192, 90, 0.05);
}
.name {
font-size: 28rpx;
color: #333;
}
text:last-child {
font-size: 40rpx;
color: #00c05a;
}
}
</style>

View File

@@ -0,0 +1,901 @@
<script lang="ts" setup>
import { getVisitPlanDetail, updateVisitPlan, updateVisitStatus, deleteVisitPlan } from '@/pagesBank/api'
import type { VisitPlan } from '@/typings/bank'
import { VisitStatus } from '@/typings/bank'
definePage({
style: {
navigationBarTitleText: '拜访计划详情',
},
})
const id = ref('')
const detail = ref<VisitPlan | null>(null)
const loading = ref(false)
const editing = ref(false)
const completing = ref(false)
// 编辑表单数据
const editForm = ref({
date: '',
topic: '',
remark: ''
})
// 完成表单数据
const completeForm = ref({
location: '广东省茂名市',
latitude: 0,
longitude: 0,
photos: [] as string[]
})
async function loadDetail() {
loading.value = true
try {
const res = await getVisitPlanDetail(id.value)
detail.value = res
if (res) {
editForm.value = {
date: res.date,
topic: res.topic,
remark: res.remark || ''
}
}
} finally {
loading.value = false
}
}
function handleEdit() {
editing.value = true
}
function handleCancelEdit() {
editing.value = false
if (detail.value) {
editForm.value = {
date: detail.value.date,
topic: detail.value.topic,
remark: detail.value.remark || ''
}
}
}
async function handleSaveEdit() {
if (!editForm.value.topic) {
uni.showToast({ title: '请输入拜访主题', icon: 'none' })
return
}
uni.showLoading({ title: '保存中...' })
try {
await updateVisitPlan(id.value, {
date: editForm.value.date,
topic: editForm.value.topic,
remark: editForm.value.remark
})
uni.showToast({ title: '保存成功', icon: 'success' })
editing.value = false
loadDetail()
} catch (error) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function handleUpdateStatus(status: VisitStatus) {
const statusText = {
[VisitStatus.PENDING]: '待拜访',
[VisitStatus.COMPLETED]: '已完成',
[VisitStatus.CANCELLED]: '已取消'
}
// 标记完成时需要填写位置和上传照片
if (status === VisitStatus.COMPLETED) {
completing.value = true
return
}
uni.showModal({
title: '状态确认',
content: `确定将拜访计划状态更新为"${statusText[status]}"吗?`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '更新中...' })
try {
await updateVisitStatus(id.value, status)
uni.showToast({ title: '更新成功', icon: 'success' })
loadDetail()
} catch (error) {
uni.showToast({ title: '更新失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
// 获取位置
function handleGetLocation() {
uni.getLocation({
type: 'gcj02',
success: (res) => {
completeForm.value.latitude = res.latitude
completeForm.value.longitude = res.longitude
// 这里可以调用逆地理编码获取地址,简化处理直接显示坐标
completeForm.value.location = `${res.latitude.toFixed(6)}, ${res.longitude.toFixed(6)}`
uni.showToast({ title: '定位成功', icon: 'success' })
},
fail: () => {
uni.showToast({ title: '定位失败,请手动输入', icon: 'none' })
}
})
}
// 上传图片
function handleUploadImage() {
uni.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
chooseImage('camera')
} else if (res.tapIndex === 1) {
chooseImage('album')
}
}
})
}
function chooseImage(sourceType: 'camera' | 'album') {
uni.chooseImage({
count: 9 - completeForm.value.photos.length,
sourceType: [sourceType],
success: (res) => {
const paths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : [res.tempFilePaths]
completeForm.value.photos.push(...paths)
}
})
}
// 删除图片
function handleRemoveImage(index: number) {
completeForm.value.photos.splice(index, 1)
}
// 预览完成表单图片
function handlePreviewCompleteImage(index: number) {
uni.previewImage({
urls: completeForm.value.photos,
current: index
})
}
// 取消完成
function handleCancelComplete() {
completing.value = false
completeForm.value = {
location: '广东省茂名市',
latitude: 0,
longitude: 0,
photos: []
}
}
// 提交完成
async function handleSubmitComplete() {
if (!completeForm.value.location) {
uni.showToast({ title: '请输入或定位位置', icon: 'none' })
return
}
if (completeForm.value.photos.length === 0) {
uni.showToast({ title: '请上传拜访场景图', icon: 'none' })
return
}
uni.showLoading({ title: '更新中...' })
try {
await updateVisitStatus(id.value, VisitStatus.COMPLETED, completeForm.value.location, completeForm.value.latitude || undefined, completeForm.value.longitude || undefined, completeForm.value.photos)
uni.showToast({ title: '更新成功', icon: 'success' })
completing.value = false
loadDetail()
} catch (error) {
uni.showToast({ title: '更新失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function handleDelete() {
uni.showModal({
title: '删除确认',
content: '确定要删除这条拜访计划吗?删除后无法恢复。',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
try {
await deleteVisitPlan(id.value)
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.showToast({ title: '删除失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
// 预览详情图片
function handlePreviewImage(index: number) {
if (detail.value?.photos) {
uni.previewImage({
urls: detail.value.photos,
current: index
})
}
}
function getStatusInfo(status: string) {
const map: Record<string, { text: string; color: string; bgColor: string }> = {
pending: { text: '待拜访', color: '#ff8f0d', bgColor: 'rgba(255, 143, 13, 0.1)' },
completed: { text: '已完成', color: '#00c05a', bgColor: 'rgba(0, 192, 90, 0.1)' },
cancelled: { text: '已取消', color: '#adb5bd', bgColor: 'rgba(173, 181, 189, 0.1)' },
}
return map[status] || { text: '未知', color: '#999', bgColor: '#f5f5f5' }
}
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return `${year}${month}${day}日 周${weekday}`
}
onLoad((options) => {
if (options?.id) {
id.value = options.id
loadDetail()
}
})
</script>
<template>
<view class="visit-detail-page">
<view v-if="loading" class="loading-box">加载中...</view>
<template v-else-if="detail">
<!-- 头部状态卡片 -->
<view class="header-card">
<view class="date-badge">
<text class="month">{{ new Date(detail.date).getMonth() + 1 }}</text>
<text class="day">{{ new Date(detail.date).getDate() }}</text>
</view>
<view class="header-info">
<text class="customer-name">{{ detail.customerName }}</text>
<text
class="status-tag"
:style="{ color: getStatusInfo(detail.status).color, backgroundColor: getStatusInfo(detail.status).bgColor }"
>
{{ getStatusInfo(detail.status).text }}
</text>
</view>
</view>
<!-- 拜访信息 -->
<view class="section">
<view class="section-header">
<view class="title">拜访信息</view>
<view v-if="!editing" class="action-btn" @click="handleEdit">编辑</view>
</view>
<view v-if="editing" class="edit-form">
<view class="form-item">
<view class="label">拜访日期</view>
<wd-datetime-picker
v-model="editForm.date"
type="date"
:min-date="Date.now()"
placeholder="请选择拜访日期"
/>
</view>
<view class="form-item">
<view class="label">拜访主题</view>
<input
v-model="editForm.topic"
placeholder="请输入拜访主题"
class="input"
/>
</view>
<view class="form-item">
<view class="label">备注</view>
<textarea
v-model="editForm.remark"
placeholder="请输入备注"
class="textarea"
:maxlength="500"
/>
</view>
<view class="edit-actions">
<button class="btn cancel" @click="handleCancelEdit">取消</button>
<button class="btn save" @click="handleSaveEdit">保存</button>
</view>
</view>
<view v-else class="info-content">
<view class="info-row">
<text class="label">拜访日期</text>
<text class="value">{{ formatDate(detail.date) }}</text>
</view>
<view class="info-row">
<text class="label">拜访主题</text>
<text class="value">{{ detail.topic }}</text>
</view>
<view v-if="detail.remark" class="info-row">
<text class="label">备注</text>
<text class="value">{{ detail.remark }}</text>
</view>
</view>
</view>
<!-- 位置信息 -->
<view class="section">
<view class="section-title">位置信息</view>
<view class="info-row">
<text class="i-carbon-location-filled"></text>
<text class="value">{{ detail.location }}</text>
</view>
</view>
<!-- 营销产品 -->
<view v-if="detail.products.length > 0" class="section">
<view class="section-title">营销产品</view>
<view class="products-list">
<view
v-for="product in detail.products"
:key="product.id"
class="product-tag"
>
{{ product.name }}
</view>
</view>
</view>
<!-- 拜访照片 -->
<view v-if="detail.photos.length > 0" class="section">
<view class="section-title">拜访照片</view>
<view class="photos-grid">
<image
v-for="(photo, index) in detail.photos"
:key="index"
:src="photo"
mode="aspectFill"
class="photo"
@click="handlePreviewImage(index)"
/>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view
v-if="detail.status === 'pending'"
class="action-item success"
@click="handleUpdateStatus(VisitStatus.COMPLETED)"
>
<text class="i-carbon-checkmark-filled"></text>
标记完成
</view>
<view
v-if="detail.status === 'pending'"
class="action-item warning"
@click="handleUpdateStatus(VisitStatus.CANCELLED)"
>
<text class="i-carbon-close"></text>
取消拜访
</view>
<view class="action-item danger" @click="handleDelete">
<text class="i-carbon-trash-can"></text>
删除计划
</view>
</view>
<!-- 完成表单弹窗 -->
<wd-popup v-model="completing" position="bottom" :close-on-click-modal="false">
<view class="complete-form">
<view class="form-header">
<text class="cancel" @click="handleCancelComplete">取消</text>
<text class="title">标记拜访完成</text>
<text class="confirm" @click="handleSubmitComplete">确定</text>
</view>
<scroll-view scroll-y class="form-content">
<!-- 位置 -->
<view class="form-item">
<view class="label required">位置</view>
<view class="location-input">
<input
v-model="completeForm.location"
placeholder="请输入地址或点击定位"
class="input"
/>
<view class="location-btn" @click="handleGetLocation">
<text class="i-carbon-location-filled"></text>
</view>
</view>
</view>
<!-- 上传图片 -->
<view class="form-item">
<view class="label required">上传拜访场景图</view>
<view class="upload-section">
<view
v-for="(photo, index) in completeForm.photos"
:key="index"
class="photo-item"
>
<image :src="photo" mode="aspectFill" @click="handlePreviewCompleteImage(index)" />
<view class="delete-btn" @click="handleRemoveImage(index)">
<text class="i-carbon-close"></text>
</view>
</view>
<view
v-if="completeForm.photos.length < 9"
class="upload-btn"
@click="handleUploadImage"
>
<text class="i-carbon-camera"></text>
<text>上传图片</text>
</view>
</view>
</view>
</scroll-view>
</view>
</wd-popup>
</template>
</view>
</template>
<style lang="scss" scoped>
.visit-detail-page {
min-height: 100vh;
background: #f8f9fa;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.header-card {
background: linear-gradient(135deg, #00c05a 0%, #34d19d 100%);
padding: 40rpx 30rpx;
display: flex;
align-items: center;
gap: 24rpx;
color: #fff;
.date-badge {
width: 100rpx;
height: 100rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
.month {
font-size: 24rpx;
margin-bottom: 4rpx;
}
.day {
font-size: 40rpx;
font-weight: 700;
}
}
.header-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
.customer-name {
font-size: 32rpx;
font-weight: 700;
}
.status-tag {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-weight: 600;
background: rgba(255, 255, 255, 0.2);
}
}
}
.section {
background: #fff;
margin: 24rpx 30rpx;
border-radius: 20rpx;
padding: 24rpx 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.02);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.title {
font-size: 30rpx;
font-weight: 700;
color: #333;
padding-left: 20rpx;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 28rpx;
background: #00c05a;
border-radius: 3rpx;
}
}
.action-btn {
font-size: 24rpx;
color: #00c05a;
font-weight: 600;
padding: 8rpx 20rpx;
background: rgba(0, 192, 90, 0.1);
border-radius: 20rpx;
}
}
.section-title {
font-size: 30rpx;
font-weight: 700;
margin-bottom: 24rpx;
padding-left: 20rpx;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 28rpx;
background: #00c05a;
border-radius: 3rpx;
}
}
}
.edit-form {
.form-item {
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
display: block;
}
.input {
width: 100%;
height: 80rpx;
background: #f8f9fa;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #333;
}
.textarea {
width: 100%;
min-height: 160rpx;
background: #f8f9fa;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
.edit-actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
.btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
&.cancel {
background: #f1f3f5;
color: #666;
}
&.save {
background: linear-gradient(135deg, #00c05a 0%, #34d19d 100%);
color: #fff;
}
}
}
}
.info-content {
.info-row {
display: flex;
padding: 20rpx 0;
border-bottom: 1rpx solid #f8f9fa;
&:last-child {
border-bottom: none;
}
.label {
font-size: 26rpx;
color: #999;
min-width: 140rpx;
}
.value {
flex: 1;
font-size: 26rpx;
color: #333;
line-height: 1.6;
}
text:first-child {
font-size: 28rpx;
color: #adb5bd;
margin-right: 12rpx;
}
}
}
.products-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.product-tag {
padding: 12rpx 24rpx;
background: rgba(0, 192, 90, 0.1);
color: #00c05a;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 500;
}
}
.photos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
.photo {
width: 100%;
aspect-ratio: 1;
border-radius: 12rpx;
}
}
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 30rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
display: flex;
gap: 16rpx;
.action-item {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
text {
font-size: 32rpx;
}
&.success {
background: linear-gradient(135deg, #00c05a 0%, #34d19d 100%);
}
&.warning {
background: linear-gradient(135deg, #ff8f0d 0%, #ffb347 100%);
}
&.danger {
background: linear-gradient(135deg, #fa4350 0%, #ff6b6b 100%);
}
}
}
.loading-box {
padding: 100rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
.complete-form {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 80vh;
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f1f3f5;
.cancel,
.confirm {
font-size: 28rpx;
color: #4d80f0;
padding: 8rpx 16rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
.form-content {
max-height: 70vh;
padding: 24rpx 30rpx;
}
.form-item {
margin-bottom: 32rpx;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 20rpx;
display: block;
&.required::before {
content: '*';
color: #fa4350;
margin-right: 4rpx;
}
}
.location-input {
display: flex;
align-items: center;
gap: 16rpx;
.input {
flex: 1;
height: 80rpx;
background: #f8f9fa;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #333;
}
.location-btn {
width: 72rpx;
height: 72rpx;
background: #00c05a;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 36rpx;
color: #fff;
}
}
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.photo-item {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
position: relative;
image {
width: 100%;
height: 100%;
}
.delete-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 24rpx;
color: #fff;
}
}
}
.upload-btn {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #d0d7de;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #adb5bd;
text:first-child {
font-size: 48rpx;
}
text:last-child {
font-size: 24rpx;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,471 @@
<script lang="ts" setup>
import { getVisitPlanList, deleteVisitPlan } from '@/pagesBank/api'
import type { VisitPlan } from '@/typings/bank'
import { VisitStatus } from '@/typings/bank'
definePage({
style: {
navigationBarTitleText: '拜访计划',
enablePullDownRefresh: true
},
})
const visitPlans = ref<VisitPlan[]>([])
const loading = ref(false)
const keyword = ref('')
const activeStatus = ref('')
const customerId = ref('')
const statusTabs = [
{ label: '全部', value: '' },
{ label: '待拜访', value: 'pending' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
]
async function loadData() {
loading.value = true
try {
const res = await getVisitPlanList({
status: activeStatus.value || undefined,
pageNum: 1,
pageSize: 20,
keyword: keyword.value
})
// 如果有客户ID筛选过滤结果
let list = res.list
if (customerId.value) {
list = list.filter(item => item.customerId === customerId.value)
}
visitPlans.value = list
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function handleSearch() {
loadData()
}
function handleTabChange(value: string) {
activeStatus.value = value
loadData()
}
function handleDetail(id: string) {
uni.navigateTo({ url: `/pagesBank/visit/detail?id=${id}` })
}
function handleDelete(id: string) {
uni.showModal({
title: '删除确认',
content: '确定要删除这条拜访计划吗?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
try {
await deleteVisitPlan(id)
uni.showToast({ title: '删除成功', icon: 'success' })
loadData()
} finally {
uni.hideLoading()
}
}
}
})
}
function getStatusInfo(status: string) {
const map: Record<string, { text: string; color: string; bgColor: string }> = {
pending: { text: '待拜访', color: '#ff8f0d', bgColor: 'rgba(255, 143, 13, 0.1)' },
completed: { text: '已完成', color: '#00c05a', bgColor: 'rgba(0, 192, 90, 0.1)' },
cancelled: { text: '已取消', color: '#adb5bd', bgColor: 'rgba(173, 181, 189, 0.1)' },
}
return map[status] || { text: '未知', color: '#999', bgColor: '#f5f5f5' }
}
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return `${month}${day}日 周${weekday}`
}
onMounted(() => {
loadData()
})
onPullDownRefresh(() => {
loadData()
})
</script>
<template>
<view class="visit-list-page">
<view class="sticky-header">
<view class="search-bar">
<view class="search-input">
<text class="i-carbon-search"></text>
<input
v-model="keyword"
placeholder="搜索客户/主题"
confirm-type="search"
@confirm="handleSearch"
/>
</view>
</view>
<view class="tabs">
<view
v-for="tab in statusTabs"
:key="tab.value"
class="tab-item"
:class="{ active: activeStatus === tab.value }"
@click="handleTabChange(tab.value)"
>
{{ tab.label }}
<view class="line"></view>
</view>
</view>
</view>
<view class="list-container">
<view v-if="loading && visitPlans.length === 0" class="loading-state">
<text>加载中...</text>
</view>
<view v-else-if="visitPlans.length === 0" class="empty-state">
<text class="i-carbon-calendar"></text>
<text>暂无拜访计划</text>
</view>
<view
v-for="item in visitPlans"
:key="item.id"
class="visit-card"
@click="handleDetail(item.id)"
>
<view class="card-header">
<view class="date-badge">
<text class="month">{{ new Date(item.date).getMonth() + 1 }}</text>
<text class="day">{{ new Date(item.date).getDate() }}</text>
</view>
<view class="header-info">
<text class="customer-name">{{ item.customerName }}</text>
<text class="topic">{{ item.topic }}</text>
</view>
<text
class="status-tag"
:style="{ color: getStatusInfo(item.status).color, backgroundColor: getStatusInfo(item.status).bgColor }"
>
{{ getStatusInfo(item.status).text }}
</text>
</view>
<view class="card-body">
<view class="info-row">
<text class="i-carbon-location-filled"></text>
<text class="location">{{ item.location }}</text>
</view>
<view v-if="item.products.length > 0" class="info-row">
<text class="i-carbon-tag"></text>
<text class="products">{{ item.products.map(p => p.name).join('、') }}</text>
</view>
<view v-if="item.photos.length > 0" class="photos-row">
<image
v-for="(photo, index) in item.photos.slice(0, 3)"
:key="index"
:src="photo"
mode="aspectFill"
class="photo"
/>
<view v-if="item.photos.length > 3" class="photo-more">
+{{ item.photos.length - 3 }}
</view>
</view>
</view>
<view class="card-footer">
<text class="time">{{ formatDate(item.date) }}</text>
<view class="actions" @click.stop>
<view class="action-btn delete" @click="handleDelete(item.id)">
<text class="i-carbon-trash-can"></text>
删除
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.visit-list-page {
min-height: 100vh;
background: #f8f9fa;
padding-bottom: 30rpx;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
padding: 20rpx 0 0;
}
.search-bar {
padding: 0 30rpx 20rpx;
.search-input {
height: 72rpx;
background: #f1f3f5;
border-radius: 36rpx;
display: flex;
align-items: center;
padding: 0 30rpx;
gap: 16rpx;
text {
font-size: 32rpx;
color: #adb5bd;
}
input {
flex: 1;
font-size: 26rpx;
}
}
}
.tabs {
display: flex;
justify-content: space-around;
border-bottom: 1rpx solid #f1f3f5;
.tab-item {
padding: 20rpx 0;
font-size: 28rpx;
color: #495057;
position: relative;
&.active {
color: #00c05a;
font-weight: 700;
.line {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6rpx;
background: #00c05a;
border-radius: 3rpx;
}
}
}
}
.list-container {
padding: 24rpx 30rpx;
}
.visit-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
.card-header {
display: flex;
align-items: flex-start;
gap: 20rpx;
margin-bottom: 20rpx;
.date-badge {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #00c05a 0%, #34d19d 100%);
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
.month {
font-size: 20rpx;
margin-bottom: 4rpx;
}
.day {
font-size: 32rpx;
font-weight: 700;
}
}
.header-info {
flex: 1;
min-width: 0;
.customer-name {
font-size: 30rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.topic {
font-size: 24rpx;
color: #666;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.status-tag {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
}
.card-body {
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
.info-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
text:first-child {
font-size: 28rpx;
color: #adb5bd;
}
.location,
.products {
flex: 1;
font-size: 26rpx;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.photos-row {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
.photo {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
}
.photo-more {
width: 120rpx;
height: 120rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28rpx;
font-weight: 600;
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1rpx solid #f1f3f5;
padding-top: 20rpx;
.time {
font-size: 24rpx;
color: #adb5bd;
}
.actions {
display: flex;
gap: 16rpx;
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 20rpx;
text {
font-size: 28rpx;
}
&.delete {
color: #fa4350;
background: rgba(250, 67, 80, 0.1);
}
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: #adb5bd;
gap: 20rpx;
text:first-child {
font-size: 80rpx;
}
text:last-child {
font-size: 28rpx;
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: #adb5bd;
text {
font-size: 28rpx;
}
}
</style>

View File

@@ -51,6 +51,55 @@ export interface BankCustomer {
joinTime: string
}
/** 拜访计划状态 */
export enum VisitStatus {
PENDING = 'pending', // 待拜访
COMPLETED = 'completed', // 已完成
CANCELLED = 'cancelled', // 已取消
}
/** 营销产品 */
export interface MarketingProduct {
id: string
name: string
type: 'loan' | 'credit' | 'settlement' | 'other'
}
/** 拜访计划 */
export interface VisitPlan {
id: string
customerId: string
customerName: string
date: string
location: string
latitude?: number
longitude?: number
products: MarketingProduct[]
topic: string
remark?: string
photos: string[]
status: VisitStatus
createdAt: string
updatedAt: string
}
/** 拜访计划创建参数 */
export interface CreateVisitPlanParams {
customerId: string
date: string
productIds: string[]
topic: string
remark?: string
}
/** 拜访计划完成参数 */
export interface CompleteVisitPlanParams {
location: string
latitude?: number
longitude?: number
photos: string[]
}
/** 银行统计指标 */
export interface BankStats {
pendingAuditStore: number // 待审核商户