Files
pj_h5_cloud/src/pages/integrated/feedback/index.vue
T

542 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="feedback-container">
<view class="feedback-tabs">
<view :class="['tab', {active: activeTab === 0} ]" @click="activeTab = 0">提交反馈</view>
<view :class="['tab', {active: activeTab === 1} ]" @click="activeTab = 1">我的反馈</view>
</view>
<view v-if="activeTab === 0">
<!-- 反馈表单 -->
<view class="feedback-form">
<view class="form-title">提交反馈</view>
<view class="form-item">
<view class="label">标题</view>
<input v-model="title" placeholder="请输入反馈标题" class="title-input" />
</view>
<view class="form-item">
<view class="label">反馈类型</view>
<picker @change="onTypeChange" :value="typeIndex" :range="typeOptions" range-key="label">
<view class="picker">{{ typeOptions[typeIndex]?.label || '请选择类型' }}</view>
</picker>
</view>
<view class="form-item">
<view class="label">反馈描述</view>
<textarea
v-model="description"
placeholder="请详细描述您的问题或建议..."
class="content-textarea"
maxlength="500"
/>
<view class="char-count">{{ description.length }}/500</view>
</view>
<view class="form-item">
<view class="label">联系方式</view>
<input v-model="contact" placeholder="手机号或邮箱(选填)" class="contact-input" />
</view>
<view class="form-item">
<view class="label">图片上传</view>
<tm-upload-photo
v-model="uploadList"
:maxCount="6"
url="/api/admin/sys-file/upload"
:header="uploadHeader"
:show-upload-list="true"
:width="180"
:height="180"
imgKey="url"
/>
</view>
<button @click="submitFeedback" class="submit-btn" :disabled="!canSubmit">
提交反馈
</button>
</view>
</view>
<view v-else>
<!-- 我的反馈列表 -->
<view class="feedback-list">
<view class="list-title">我的反馈</view>
<view v-if="error" class="error-tip">
加载失败,请 <text class="retry-btn" @click="refreshList">重试</text>
</view>
<view v-else-if="feedbackList.length === 0" class="empty-tip">
暂无反馈记录
</view>
<view v-else>
<view
v-for="item in feedbackList"
:key="item.id"
class="feedback-item"
@click="viewDetail(item)"
>
<view class="item-header">
<view class="type-tag" :class="getTypeClass(item.type)">{{ getTypeLabel(item.type) }}</view>
<view class="status-tag" :class="getStatusClass(item.status)">{{ getStatusText(item.status) }}</view>
</view>
<view class="item-content">
<view class="item-title">{{ item.title }}</view>
<view class="item-desc">{{ item.description }}</view>
<view v-if="item.picUrl" class="image-list">
<image
v-for="(img, idx) in item.picUrl.split(',').filter(Boolean)"
:key="idx"
:src="filePath(img)"
class="item-img"
mode="aspectFill"
/>
</view>
</view>
<view class="item-footer">
<text class="time">{{ formatTime(item.createTime) }}</text>
<text v-if="item.reply" class="reply-count">已回复</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import meetingRoomApi from '@/sheep/api/meetingRoom'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { filePath } from '@/sheep/utils/file'
import { useDefaultHeader } from '@/sheep/hooks/use-token'
const typeOptions = ref<{ value: string; label: string }[]>([])
const typeIndex = ref(0)
const title = ref('')
const description = ref('')
const contact = ref('')
const feedbackList = ref<any[]>([])
const activeTab = ref(0)
const page = ref(1)
const pageSize = 5
const hasMore = ref(true)
const loading = ref(false)
const error = ref(false)
const uploadList = ref<any[]>([])
const uploadHeader = ref({})
const canSubmit = computed(() => {
return title.value.trim() && description.value.trim()
})
async function loadTypeOptions() {
try {
const res: any = await meetingRoomApi.getFeedbackType()
if (res.data && Array.isArray(res.data.data)) {
typeOptions.value = res.data.data.map(item => ({
value: item.value,
label: item.label
}))
}
} catch (e) {
uni.showToast({ title: '获取反馈类型失败', icon: 'none' })
}
}
function onTypeChange(e: any) {
typeIndex.value = e.detail.value
}
function getTypeLabel(type: string) {
const option = typeOptions.value.find(item => item.value === type)
return option ? option.label : type
}
function getTypeClass(type: string) {
const classMap: Record<string, string> = {
'投诉': 'type-complain',
'建议': 'type-suggest',
'BUG报告': 'type-bug',
'功能需求': 'type-feature'
}
return classMap[type] || 'type-other'
}
function getStatusText(status: string | number) {
if (status === 1 || status === '1') return '已处理'
// 只要不是1都显示未处理
return '未处理'
}
function getStatusClass(status: string | number) {
if (status === 1 || status === '1') return 'status-completed'
// 只要不是1都用未处理样式
return 'status-pending'
}
function formatTime(time: string) {
if (!time) return ''
return new Date(time).toLocaleDateString()
}
async function submitFeedback() {
if (!canSubmit.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
try {
uni.showLoading({ title: '提交中...' })
let picUrlArr: any[] = []
uploadList.value.forEach(item => {
if (item.response) {
try {
const res = JSON.parse(item.response)
if (res.code === 0 && res.data && res.data.url) {
picUrlArr.push(res.data.url)
}
} catch {}
}
})
const data = {
title: title.value.trim(),
type: typeOptions.value[typeIndex.value].value,
description: description.value.trim(),
contact: contact.value.trim(),
picUrl: picUrlArr.join(',')
}
const response: any = await meetingRoomApi.submitFeedback(data)
uni.hideLoading()
if (response.code === 0) {
uni.showToast({ title: '提交成功', icon: 'success' })
// 清空表单
title.value = ''
description.value = ''
contact.value = ''
typeIndex.value = 0
uploadList.value = []
// 刷新列表
loadFeedbackList()
} else {
uni.showToast({ title: response.msg || '提交失败', icon: 'none' })
}
} catch (error) {
uni.hideLoading()
uni.showToast({ title: '提交失败', icon: 'none' })
}
}
function viewDetail(item: any) {
uni.navigateTo({
url: `/pages/integrated/feedback/detail?id=${item.id}`
})
}
async function loadFeedbackList(isLoadMore = false, autoFill = false) {
if (loading.value || (!hasMore.value && isLoadMore)) return
loading.value = true
error.value = false
try {
const response: any = await meetingRoomApi.getFeedbackList({
current: page.value,
size: pageSize
})
if (response.code === 0 && response.data) {
const records = response.data.records || []
if (isLoadMore) {
feedbackList.value = feedbackList.value.concat(records)
} else {
feedbackList.value = records
}
hasMore.value = records.length === pageSize
if (hasMore.value) page.value += 1
} else {
error.value = true
hasMore.value = false
uni.showToast({ title: response.msg || '加载失败', icon: 'none' })
}
} catch (e) {
error.value = true
hasMore.value = false
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
// H5端自动补齐:只有 autoFill=true 时才自动补齐
if (autoFill) {
await nextTick()
if (hasMore.value) {
const listDom = document.querySelector('.feedback-list')
if (listDom && listDom.scrollHeight <= window.innerHeight) {
await loadFeedbackList(true, true)
}
}
}
}
}
function refreshList() {
page.value = 1
hasMore.value = true
loadFeedbackList(false, true)
}
// 监听页面触底事件,下拉加载更多
onReachBottom(() => {
if (activeTab.value === 1) {
loadFeedbackList(true, false)
}
})
// 切换tab时重置分页并刷新
watch(activeTab, (val) => {
if (val === 1) {
refreshList()
}
})
onPullDownRefresh(() => {
if (activeTab.value === 1) {
page.value = 1
hasMore.value = true
loadFeedbackList(false, true).then(() => {
uni.stopPullDownRefresh()
})
} else {
uni.stopPullDownRefresh()
}
})
onMounted(() => {
uploadHeader.value = useDefaultHeader()
loadTypeOptions().then(() => {
if (activeTab.value === 1) {
refreshList()
}
})
})
</script>
<style lang="scss" scoped>
.feedback-container {
padding: 24rpx;
background: #f5f5f5;
min-height: 100vh;
}
.feedback-tabs {
display: flex;
background: #fff;
border-radius: 16rpx 16rpx 0 0;
overflow: hidden;
margin-bottom: 24rpx;
.tab {
flex: 1;
text-align: center;
padding: 28rpx 0;
font-size: 30rpx;
color: #888;
background: #f7f8fa;
&.active {
color: #1777FF;
background: #fff;
font-weight: bold;
border-bottom: 4rpx solid #1777FF;
}
}
}
.feedback-form {
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
.form-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
}
.form-item {
margin-bottom: 24rpx;
.label {
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.title-input {
height: 80rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 8rpx;
font-size: 28rpx;
}
.picker {
height: 80rpx;
line-height: 80rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 8rpx;
font-size: 28rpx;
}
.content-textarea {
width: 100%;
height: 200rpx;
padding: 24rpx;
background: #f7f8fa;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.char-count {
text-align: right;
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.contact-input {
height: 80rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 8rpx;
font-size: 28rpx;
}
}
.submit-btn {
width: 100%;
height: 88rpx;
background: #007aff;
color: #fff;
border-radius: 8rpx;
font-size: 32rpx;
&:disabled {
background: #ccc;
}
}
}
.feedback-list {
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
.list-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
}
.error-tip {
text-align: center;
color: #f56c6c;
font-size: 28rpx;
padding: 60rpx 0;
}
.retry-btn {
color: #007aff;
text-decoration: underline;
cursor: pointer;
}
.empty-tip {
text-align: center;
color: #999;
font-size: 28rpx;
padding: 60rpx 0;
}
.feedback-item {
border-bottom: 1rpx solid #f0f0f0;
padding: 24rpx 0;
&:last-child {
border-bottom: none;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.type-tag {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
&.type-complain {
background: #ffebee;
color: #d32f2f;
}
&.type-suggest {
background: #e3f2fd;
color: #1976d2;
}
&.type-bug {
background: #f3e5f5;
color: #7b1fa2;
}
&.type-feature {
background: #e8f5e8;
color: #388e3c;
}
&.type-other {
background: #f5f5f5;
color: #666;
}
}
.status-tag {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
&.status-pending {
background: #fff3e0;
color: #f57c00;
}
&.status-processing {
background: #e8f5e8;
color: #388e3c;
}
&.status-completed {
background: #e3f2fd;
color: #1976d2;
}
&.status-closed {
background: #f5f5f5;
color: #999;
}
}
}
.item-content {
.item-title {
font-size: 28rpx;
color: #222;
font-weight: bold;
margin-bottom: 8rpx;
}
.item-desc {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 8rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
background: #f7f8fa;
object-fit: cover;
}
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
.time {
font-size: 24rpx;
color: #999;
}
.reply-count {
font-size: 24rpx;
color: #007aff;
}
}
}
}
</style>