hookehuyr

feat(反馈): 完成意见反馈模块开发和调试

新增功能:
- 实现意见反馈列表页面(分页、图片预览、自动刷新)
- 实现意见反馈提交页面(类型选择、图片上传、审核提示)
- 添加图片上传功能(最多3张,5MB限制,支持审核)
- 实现从列表页跳转提交页的导航流程

技术优化:
- 修复生命周期钩子导入错误(useShow → useDidShow)
- 修复图片显示错误(数组格式处理)
- 使用自定义 CSS spinner 替代 NutUI Loading
- 添加 useDidShow 实现返回列表时自动刷新

文档更新:
- 更新 API 文档(addAPI、images 数组格式)
- 更新项目 CHANGELOG

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -5,6 +5,57 @@
---
## [2026-02-03] - 意见反馈模块完成
### 新增
- 实现意见反馈列表功能
- 支持分页加载反馈记录
- 显示反馈类型、状态、内容、图片
- 支持图片预览功能
- 从提交页返回时自动刷新列表
- 实现意见反馈提交功能
- 支持选择反馈类型(功能建议/问题反馈/其他)
- 支持上传最多 3 张图片(5MB 限制)
- 图片审核不通过时提示用户
- 提交成功后返回列表页
### 修复
- 修复 `useShow` 导入错误,改用 `useDidShow` 生命周期钩子
- 修复 `onMounted` 导入错误,从 Vue 导入而非 Taro
- 修复图片显示错误,`images` 字段改为数组格式处理
- 移除 NutUI Loading 组件,使用自定义 CSS spinner
### 优化
- 调整导航流程:我的 → 反馈列表 → 提交反馈
- 添加页面下拉刷新支持
- 使用 `useDidShow` 实现列表自动刷新
### 文档
- 更新 API 文档 `docs/api-specs/feedback/add.md`
- 修正接口名称为 `addAPI`
- 更新 `images` 参数为数组格式
- 添加完整的代码示例
---
**详细信息**
- **影响文件**:
- `src/pages/feedback-list/index.vue`(新增)
- `src/pages/feedback-list/index.config.js`(新增)
- `src/pages/feedback/index.vue`(更新)
- `src/api/feedback.js`(更新)
- `src/app.config.js`(注册路由)
- `src/pages/mine/index.vue`(导航入口)
- `docs/api-specs/feedback/add.md`(文档更新)
- **技术栈**: Vue 3, Taro 4, NutUI, Composition API
- **测试状态**: ✅ 已通过
- **备注**:
- 反馈类别:1=功能建议, 3=问题反馈, 7=其他问题
- 图片上传使用 `Taro.uploadFile`,支持图片审核
- 列表使用 `useDidShow` 实现返回时自动刷新
---
## 📋 变更记录模板
每次添加新记录时,请使用以下标准格式:
......
# 提交意见反馈
## 接口说明
- **接口名称**: `addAPI`
- **接口路径**: `/srv/?a=feedback&t=add`
- **请求方式**: POST
- **调用示例**: `addAPI({ category: '1', note: '反馈内容', images: 'url1,url2' })`
## 请求参数
### Body 参数(application/x-www-form-urlencoded)
| 参数名 | 类型 | 必填 | 说明 | 示例值 |
|--------|------|------|------|--------|
| category | string | 是 | 反馈类别。1=功能建议, 3=问题反馈, 7=其他问题 | `"1"` |
| note | string | 是 | 反馈内容 | `"test"` |
| images | array | 否 | 图片 URL 数组 | `["url1", "url2"]` |
## 响应数据
### 成功响应
```json
{
"code": 1,
"msg": "提交成功"
}
```
### 字段说明
| 字段名 | 类型 | 说明 |
|--------|------|------|
| code | number | 状态码,1=成功 |
| msg | string | 消息描述 |
## 代码示例
```javascript
import { addAPI } from '@/api/feedback'
// 提交意见反馈
const res = await addAPI({
category: '1', // 反馈类别:1=功能建议
note: '这是反馈内容', // 反馈内容
images: ['url1', 'url2', 'url3'] // 图片数组(可选)
})
if (res.code === 1) {
console.log('提交成功')
}
```
## 注意事项
1. `category` 参数值说明:
- `"1"` - 功能建议
- `"3"` - 问题反馈
- `"7"` - 其他问题
2. `images` 参数是可选的,直接传递图片 URL 数组
3. 后端统一返回格式为 `{ code, msg }``code === 1` 表示成功
## OpenAPI Specification
```yaml
......@@ -53,27 +116,24 @@ paths:
example: add
type: string
category:
description: 反馈类别。1=功能建议, 3=界面设计, 5=车辆新鲜, 7=其他问题
description: 反馈类别。1=功能建议, 3=问题反馈, 7=其他问题
example: '1'
type: string
note:
description: 反馈内容
example: '3'
type: string
contact:
description: 用户留下的联系方式
example: '3'
example: test
type: string
images:
type: array
items:
type: string
description: 图片
type: string
required:
- f
- a
- t
- category
- note
- contact
- images
examples: {}
responses:
......@@ -99,7 +159,7 @@ paths:
x-apifox-ordering: 0
security: []
x-apifox-folder: 意见反馈
x-apifox-status: developing
x-apifox-status: testing
x-run-in-apifox: https://app.apifox.com/web/project/7792797/apis/api-413906671-run
components:
schemas: {}
......
import { fn, fetch } from '@/api/fn';
const Api = {
Add: '/srv/?a=feedback&t=add',
List: '/srv/?a=feedback&t=list',
SubmitFeedback: '/srv/?a=feedback&t=add',
}
/**
* @description 提交意见反馈
* @remark
* @param {Object} params 请求参数
* @param {string} params.category 反馈类别。1=功能建议, 3=问题反馈, 7=其他问题
* @param {string} params.note 反馈内容
* @param {array} params.images 图片
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* }>}
*/
export const addAPI = (params) => fn(fetch.post(Api.Add, params));
/**
* @description 意见反馈列表
* @remark
* @param {Object} params 请求参数
......@@ -29,19 +44,3 @@ const Api = {
* }>}
*/
export const listAPI = (params) => fn(fetch.get(Api.List, params));
/**
* @description 提交意见反馈
* @remark
* @param {Object} params 请求参数
* @param {string} params.category 反馈类别。1=功能建议, 3=界面设计, 5=车辆新鲜, 7=其他问题
* @param {string} params.note 反馈内容
* @param {string} params.contact 用户留下的联系方式
* @param {string} params.images 图片
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* }>}
*/
export const submitFeedbackAPI = (params) => fn(fetch.post(Api.SubmitFeedback, params));
......
......@@ -22,6 +22,7 @@ const pages = [
'pages/plan-submit-result/index',
'pages/favorites/index',
'pages/avatar/index',
'pages/feedback-list/index',
'pages/feedback/index',
'pages/login/index',
'pages/help-center/index',
......
export default {
navigationBarTitleText: '我的反馈',
navigationStyle: 'custom',
enablePullDownRefresh: true,
backgroundColor: '#f5f5f5'
}
<!--
* @Date: 2026-02-03
* @Description: 意见反馈列表页面
-->
<template>
<view class="min-h-screen bg-gray-50">
<NavHeader title="我的反馈" />
<scroll-view
scroll-y
class="feedback-scroll"
:style="{ height: scrollHeight + 'px' }"
@scrolltolower="onScrollToLower"
>
<view class="p-[32rpx] pb-[250rpx]">
<!-- Feedback List -->
<view v-if="loading" class="flex justify-center items-center py-[100rpx]">
<view class="loading-spinner"></view>
</view>
<view v-else-if="feedbackList.length === 0" class="flex flex-col items-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">暂无反馈记录</text>
</view>
<view v-else class="space-y-[24rpx]">
<view
v-for="item in feedbackList"
:key="item.id"
class="bg-white rounded-[24rpx] p-[32rpx] shadow-sm"
>
<!-- Header: Type & Status -->
<view class="flex justify-between items-center mb-[20rpx]">
<view
class="px-[20rpx] py-[8rpx] rounded-full text-[24rpx]"
:class="getTypeClass(item.category)"
>
{{ getTypeLabel(item.category) }}
</view>
<view
class="px-[20rpx] py-[8rpx] rounded-full text-[24rpx]"
:class="item.status === 5 ? 'bg-green-100 text-green-600' : 'bg-orange-100 text-orange-600'"
>
{{ item.status === 5 ? '已处理' : '待处理' }}
</view>
</view>
<!-- Content -->
<view class="text-[28rpx] text-gray-900 mb-[20rpx] leading-relaxed">
{{ item.note }}
</view>
<!-- Images -->
<view v-if="item.images && item.images.length > 0" class="flex gap-[16rpx] mb-[20rpx]">
<image
v-for="(img, index) in item.images"
:key="index"
:src="img"
mode="aspectFill"
class="w-[120rpx] h-[120rpx] rounded-[12rpx]"
@tap="previewImage(item.images, index)"
/>
</view>
<!-- Contact -->
<view v-if="item.contact" class="text-[24rpx] text-gray-500 mb-[20rpx]">
联系方式:{{ item.contact }}
</view>
<!-- Reply Section -->
<view v-if="item.reply" class="bg-blue-50 rounded-[16rpx] p-[24rpx]">
<view class="text-[24rpx] text-gray-500 mb-[8rpx]">
客服回复:{{ item.reply_time || '' }}
</view>
<view class="text-[28rpx] text-gray-900 leading-relaxed">
{{ item.reply }}
</view>
</view>
</view>
</view>
<!-- Load More -->
<view v-if="hasMore && !loading" class="flex justify-center mt-[40rpx]">
<nut-button type="default" size="small" @click="loadMore">
加载更多
</nut-button>
</view>
</view>
</scroll-view>
<!-- Fixed Bottom Button -->
<view class="fixed bottom-0 left-0 right-0 p-[32rpx] bg-white border-t border-gray-200">
<nut-button type="primary" block class="!h-[88rpx] !rounded-[44rpx] !text-[32rpx]" @click="goToFeedback">
反馈意见
</nut-button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import Taro, { useDidShow } from '@tarojs/taro'
import { listAPI } from '@/api/feedback'
const go = useGo()
/** @type {import('vue').Ref<number>} 系统信息(用于计算滚动高度) */
const systemInfo = ref(null)
/** @type {import('vue').Ref<boolean>} 加载状态 */
const loading = ref(false)
/** @type {import('vue').Ref<Array>} 反馈列表 */
const feedbackList = ref([])
/** @type {import('vue').Ref<number>} 当前页码 */
const currentPage = ref(0)
/** @type {import('vue').Ref<number>} 每页数量 */
const pageSize = ref(10)
/** @type {import('vue').Ref<boolean>} 是否有更多数据 */
const hasMore = ref(true)
/** @type {import('vue').ComputedRef<number>} 滚动区域高度 */
const scrollHeight = computed(() => {
if (!systemInfo.value) return 500
// 导航栏高度 + 状态栏高度 + 底部按钮高度 + padding
const navBarHeight = 44 // 导航栏默认高度
const statusBarHeight = systemInfo.value.statusBarHeight || 0
const bottomHeight = 88 + 32 // 按钮高度 + padding
return systemInfo.value.windowHeight - bottomHeight
})
/**
* @description 获取反馈类型标签
* @param {string} category 类别值:1=功能建议, 3=问题反馈, 7=其他问题
* @returns {string} 类别标签
*/
const getTypeLabel = (category) => {
const map = {
'1': '功能建议',
'3': '问题反馈',
'7': '其他问题'
}
return map[category] || '其他'
}
/**
* @description 获取反馈类型样式类
* @param {string} category 类别值
* @returns {string} 样式类名
*/
const getTypeClass = (category) => {
const map = {
'1': 'bg-blue-100 text-blue-600',
'3': 'bg-red-100 text-red-600',
'7': 'bg-gray-100 text-gray-600'
}
return map[category] || 'bg-gray-100 text-gray-600'
}
/**
* @description 预览图片
* @param {Array<string>} urls 图片 URL 列表
* @param {number} current 当前图片索引
*/
const previewImage = (urls, current) => {
Taro.previewImage({
current: urls[current],
urls: urls
})
}
/**
* @description 加载反馈列表
* @param {boolean} isLoadMore 是否为加载更多
*/
const loadFeedbackList = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const res = await listAPI({
page: currentPage.value,
limit: pageSize.value
})
if (res.code === 1) {
const newList = res.data.list || []
if (isLoadMore) {
feedbackList.value.push(...newList)
} else {
feedbackList.value = newList
}
// 判断是否还有更多数据
hasMore.value = newList.length >= pageSize.value
} else {
Taro.showToast({ title: res.msg || '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载反馈列表失败:', err)
Taro.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
loading.value = false
}
}
/**
* @description 加载更多
*/
const loadMore = () => {
if (!hasMore.value || loading.value) return
currentPage.value++
loadFeedbackList(true)
}
/**
* @description 滚动到底部时自动加载
*/
const onScrollToLower = () => {
if (hasMore.value && !loading.value) {
loadMore()
}
}
/**
* @description 跳转到反馈提交页面
*/
const goToFeedback = () => {
go('/pages/feedback/index')
}
/**
* @description 页面首次加载时获取系统信息
*/
onMounted(() => {
// 获取系统信息
Taro.getSystemInfo({
success: (res) => {
systemInfo.value = res
},
fail: () => {
// 使用默认值
systemInfo.value = {
windowHeight: 667,
statusBarHeight: 44
}
}
})
})
/**
* @description 页面显示时刷新列表(从提交页返回时也会触发)
*/
useDidShow(() => {
// 重置为第一页
currentPage.value = 0
feedbackList.value = []
// 加载反馈列表
loadFeedbackList()
})
</script>
<style lang="less">
.feedback-scroll {
box-sizing: border-box;
}
.space-y-\[24rpx\] > * + * {
margin-top: 24rpx;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
......@@ -3,7 +3,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-31 14:40:32
* @FilePath: /manulife-weapp/src/pages/feedback/index.vue
* @Description: 文件描述
* @Description: 意见反馈页面
-->
<template>
<view class="min-h-screen bg-gray-50 pb-[200rpx]">
......@@ -45,12 +45,33 @@
<!-- Screenshots -->
<view class="bg-white rounded-[24rpx] p-[32rpx] mb-[40rpx] shadow-sm">
<view class="text-[30rpx] font-bold text-gray-900 mb-[24rpx]">添加截图(可选)</view>
<nut-uploader
url="https://xxx"
v-model:file-list="fileList"
maximum="3"
>
</nut-uploader>
<view class="flex gap-[24rpx]">
<!-- Uploaded Images -->
<view
v-for="(img, index) in uploadedImages"
:key="index"
class="relative w-[160rpx] h-[160rpx] rounded-[16rpx] overflow-hidden"
>
<image :src="img" mode="aspectFill" class="w-full h-full" />
<view
class="absolute top-0 right-0 bg-red-500 text-white w-[40rpx] h-[40rpx] flex items-center justify-center"
@tap="removeImage(index)"
>
<text class="text-[24rpx]">×</text>
</view>
</view>
<!-- Upload Button -->
<view
v-if="uploadedImages.length < 3"
class="w-[160rpx] h-[160rpx] rounded-[16rpx] border-2 border-dashed border-gray-300 flex flex-col items-center justify-center"
@tap="chooseImage"
>
<text class="text-[48rpx] text-gray-400">+</text>
<text class="text-[24rpx] text-gray-400 mt-[8rpx]">上传图片</text>
</view>
</view>
<view class="text-[22rpx] text-gray-400 mt-[16rpx]">最多上传3张图片,每张不超过5MB</view>
</view>
<!-- Submit Button -->
......@@ -67,40 +88,147 @@ import { ref } from 'vue'
import TabBar from '@/components/TabBar.vue'
import NavHeader from '@/components/NavHeader.vue'
import Taro from '@tarojs/taro'
import { addAPI } from '@/api/feedback'
import BASE_URL from '@/utils/config'
const selectedType = ref('suggestion')
const description = ref('')
const fileList = ref([])
/**
* 反馈类型选项(对应后端 category 值)
* 1=功能建议, 3=问题反馈, 7=其他问题
* @type {Array<{label: string, value: string}>}
*/
const types = [
{ label: '功能建议', value: 'suggestion' },
{ label: '问题反馈', value: 'issue' },
{ label: '其他', value: 'other' }
{ label: '功能建议', value: '1' },
{ label: '问题反馈', value: '3' },
{ label: '其他', value: '7' }
]
const onSubmit = () => {
/** @type {import('vue').Ref<string>} 选中的反馈类型 */
const selectedType = ref('1')
/** @type {import('vue').Ref<string>} 问题描述 */
const description = ref('')
/** @type {import('vue').Ref<Array<string>>} 已上传的图片 URL 列表 */
const uploadedImages = ref([])
/**
* @description 选择图片并上传(参考头像上传逻辑)
*/
const chooseImage = () => {
Taro.chooseImage({
count: 3 - uploadedImages.value.length, // 剩余可上传数量
sizeType: ['compressed'], // 使用压缩图
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFile = res.tempFiles[0]
// 检查文件大小(5MB限制)
if (tempFile.size > 5 * 1024 * 1024) {
Taro.showToast({
title: '图片大小不能超过5MB',
icon: 'none',
})
return
}
// 显示上传进度
Taro.showLoading({ title: '上传中...', mask: true })
// 使用 Taro.uploadFile 上传到服务器(参考头像上传)
Taro.uploadFile({
url: BASE_URL + '/admin/?m=srv&a=upload&image_audit=1',
filePath: tempFile.path,
name: 'file',
success: (uploadRes) => {
Taro.hideLoading()
const data = JSON.parse(uploadRes.data)
// 检查是否为审核不通过
if (data.data && data.data.audit_code == -1) {
Taro.showModal({
title: '温馨提示',
content: data.msg || '图片审核不通过',
showCancel: false
})
return
}
if (data.code === 0) { // 注意:后端 code=0 表示成功
uploadedImages.value.push(data.data.src)
Taro.showToast({
title: '上传成功',
icon: 'success'
})
} else {
Taro.showToast({
title: data.msg || '上传失败',
icon: 'none'
})
}
},
fail: () => {
Taro.hideLoading()
Taro.showToast({
title: '上传失败,请稍后重试',
icon: 'none'
})
}
})
}
})
}
/**
* @description 移除图片
* @param {number} index 图片索引
*/
const removeImage = (index) => {
uploadedImages.value.splice(index, 1)
}
/**
* @description 提交意见反馈
*/
const onSubmit = async () => {
// 验证必填项
if (!description.value) {
Taro.showToast({ title: '请输入问题描述', icon: 'none' })
return
}
Taro.showLoading({ title: '提交中...' })
Taro.showLoading({ title: '提交中...', mask: true })
try {
// 调用 API 提交反馈
const res = await addAPI({
category: selectedType.value, // 反馈类别:1=功能建议, 3=问题反馈, 7=其他问题
note: description.value, // 反馈内容
images: uploadedImages.value // 图片 URL 数组(可选)
})
// Simulate API call
setTimeout(() => {
Taro.hideLoading()
Taro.showToast({ title: '提交成功', icon: 'success' })
// Reset form
description.value = ''
fileList.value = []
selectedType.value = 'suggestion'
if (res.code === 1) {
Taro.showToast({ title: '提交成功', icon: 'success' })
// Navigate back or stay
setTimeout(() => {
// 重置表单
description.value = ''
uploadedImages.value = []
selectedType.value = '1'
// 延迟返回
setTimeout(() => {
Taro.navigateBack()
}, 1500)
}, 1000)
}, 1500)
} else {
Taro.showToast({ title: res.msg || '提交失败', icon: 'none' })
}
} catch (err) {
Taro.hideLoading()
console.error('提交反馈失败:', err)
Taro.showToast({ title: '网络异常,请重试', icon: 'none' })
}
}
</script>
......
......@@ -123,7 +123,7 @@ const menuItems = [
{ title: '我的计划书', icon: 'order', path: '/pages/plan/index' },
{ title: '我的收藏', icon: 'star', path: '/pages/favorites/index' },
{ title: '帮助中心', icon: 'service', path: '/pages/help-center/index' },
{ title: '意见反馈', icon: 'edit', path: '/pages/feedback/index' }
{ title: '意见反馈', icon: 'edit', path: '/pages/feedback-list/index' }
]
const handleMenuClick = (item) => {
......