feat: 意见反馈功能

新增完整反馈闭环:
- 后端: Feedback 模块 (schema+service+controller+module)
  - POST /api/feedback (用户提交)
  - GET /api/feedback (管理员列表)
  - PATCH /api/feedback/:id/resolve (管理员标记已处理)
- 前端: pages/feedback/feedback.vue
  - 三种反馈类型: 问题反馈/改进建议/点赞鼓励
  - 文本输入 + 联系方式选填
  - 提交后显示成功提示
- 用户页新增'意见反馈'菜单入口
- 管理后台可通过 API 查看和管理反馈
This commit is contained in:
yuzhiran
2026-07-06 11:13:08 +08:00
parent 4e4e1ca271
commit 4180eae944
8 changed files with 217 additions and 1 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } },
{ "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } },
{ "path": "pages/review/review", "style": { "navigationBarTitleText": "面试复盘分析" } },
{ "path": "pages/career/career", "style": { "navigationBarTitleText": "AI择业顾问" } }
{ "path": "pages/career/career", "style": { "navigationBarTitleText": "AI择业顾问" } },
{ "path": "pages/feedback/feedback", "style": { "navigationBarTitleText": "意见反馈" } }
],
"tabBar": {
"color": "#999999",
+113
View File
@@ -0,0 +1,113 @@
<template>
<view class="page">
<view class="form-card">
<text class="section-title">反馈类型</text>
<view class="type-row">
<view class="type-option" :class="{ active: type === 'bug' }" @click="type = 'bug'">
<text class="type-icon">🐛</text>
<text class="type-label">问题反馈</text>
</view>
<view class="type-option" :class="{ active: type === 'suggestion' }" @click="type = 'suggestion'">
<text class="type-icon">💡</text>
<text class="type-label">改进建议</text>
</view>
<view class="type-option" :class="{ active: type === 'praise' }" @click="type = 'praise'">
<text class="type-icon">👍</text>
<text class="type-label">点赞鼓励</text>
</view>
</view>
</view>
<view class="form-card">
<text class="section-title">反馈内容 <text class="required">*</text></text>
<textarea class="content-input" v-model="content" placeholder="请详细描述您的问题或建议,这将帮助我们持续改进产品..." :maxlength="1000" :auto-height="true" />
<text class="char-count">{{ content.length }}/1000</text>
</view>
<view class="form-card">
<text class="section-title">联系方式选填</text>
<input class="contact-input" v-model="contact" placeholder="手机号或微信号,方便我们联系您" />
</view>
<button class="submit-btn" :class="{ disabled: !content.trim() || submitting }" @click="submitFeedback" :disabled="submitting">
{{ submitting ? '提交中...' : '提交反馈' }}
</button>
<view class="toast-success" v-if="submitted">
<text class="toast-icon"></text>
<text class="toast-text">感谢您的反馈我们会认真处理</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { api } from '../../config'
const type = ref('suggestion')
const content = ref('')
const contact = ref('')
const submitting = ref(false)
const submitted = ref(false)
const submitFeedback = async () => {
if (!content.value.trim() || submitting.value) return
submitting.value = true
try {
const token = uni.getStorageSync('token')
const res = await uni.request({
url: api('/feedback'),
method: 'POST',
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { type: type.value, content: content.value.trim(), contact: contact.value.trim() },
})
if (res.statusCode >= 200 && res.statusCode < 300) {
submitted.value = true
} else {
uni.showToast({ title: res.data?.message || '提交失败', icon: 'none' })
}
} catch {
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
// #ifdef MP-WEIXIN
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
onShareAppMessage(() => ({ title: '意见反馈 - 职引', path: '/pages/feedback/feedback' }))
onShareTimeline(() => ({ title: '意见反馈 - 职引' }))
// #endif
</script>
<style scoped>
.page { background: var(--color-bg); min-height: 100vh; padding: 32rpx; }
.form-card { background: #FFF; border-radius: var(--radius-lg); padding: 28rpx 32rpx; margin-bottom: 20rpx; }
.section-title { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 20rpx; }
.required { color: #EF4444; }
.type-row { display: flex; gap: 16rpx; }
.type-option {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8rpx;
padding: 20rpx 12rpx; border-radius: var(--radius-md); border: 2rpx solid var(--color-border);
transition: all 0.2s;
}
.type-option.active { border-color: var(--color-primary); background: #EEF2FF; }
.type-icon { font-size: 36rpx; }
.type-label { font-size: 22rpx; color: var(--color-text-secondary); }
.type-option.active .type-label { color: var(--color-primary); font-weight: 600; }
.content-input { width: 100%; font-size: 26rpx; color: var(--color-text); line-height: 1.7; min-height: 200rpx; padding: 0; }
.char-count { text-align: right; font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 8rpx; }
.contact-input { width: 100%; font-size: 26rpx; color: var(--color-text); padding: 8rpx 0; }
.submit-btn {
width: 100%; height: 88rpx; line-height: 88rpx;
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
color: #FFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none;
}
.submit-btn.disabled { background: var(--color-border); }
.toast-success {
margin-top: 32rpx; background: #ECFDF5; border-radius: var(--radius-md);
padding: 24rpx; display: flex; align-items: center; gap: 12rpx;
}
.toast-icon { font-size: 32rpx; }
.toast-text { font-size: 26rpx; color: #065F46; line-height: 1.5; }
</style>
+6
View File
@@ -104,6 +104,11 @@
</button>
</view>
<!-- #endif -->
<view class="menu-item" @click="goFeedback">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon">📝</text></view>
<text class="menu-text">意见反馈</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goAbout">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">关于</text>
@@ -358,6 +363,7 @@ const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
const goSharePage = () => uni.navigateTo({ url: '/pages/share/share' })
const goContributePage = () => uni.navigateTo({ url: '/pages/contribute/contribute' })
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
const goFeedback = () => uni.navigateTo({ url: '/pages/feedback/feedback' })
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
const doLogout = () => {