feat: 隐私政策 + 意见反馈系统

- 新增隐私政策页面,满足小程序审核要求
- 新增意见反馈功能(用户提交 + 管理员查看)
- 后端新增 feedbacks 表和反馈 API
- 管理后台新增反馈管理入口
- 我的页面添加意见反馈、隐私政策入口
This commit is contained in:
2026-06-08 10:16:28 +08:00
parent 8cf240f76b
commit 29614fdb70
10 changed files with 1026 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import { request } from '@/utils/request'
/** 提交反馈 */
export function submitFeedback(data: {
type: string
content: string
contact?: string
}) {
return request('/feedback', { method: 'POST', data })
}
/** 获取反馈列表(管理员) */
export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) {
return request('/feedback', { params })
}
/** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: string) {
return request(`/feedback/${id}/status`, { method: 'PUT', data: { status } })
}

View File

@@ -96,6 +96,27 @@
"navigationBarTitleText": "公告管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/privacy/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "隐私政策"
}
},
{
"path": "pages/feedback/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "意见反馈"
}
},
{
"path": "pages/admin/feedback",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "反馈管理"
}
}
],
"globalStyle": {

View File

@@ -0,0 +1,418 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">反馈管理</text>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 状态筛选 -->
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterStatus === '' }" @tap="switchStatus('')">全部</view>
<view class="tab" :class="{ active: filterStatus === 'pending' }" @tap="switchStatus('pending')">待处理</view>
<view class="tab" :class="{ active: filterStatus === 'processed' }" @tap="switchStatus('processed')">已处理</view>
<view class="tab" :class="{ active: filterStatus === 'ignored' }" @tap="switchStatus('ignored')">已忽略</view>
</view>
</view>
<!-- 列表 -->
<scroll-view class="list" scroll-y @scrolltolower="loadMore">
<view v-for="item in list" :key="item.id" class="feedback-card">
<view class="card-header">
<view class="user-info">
<image v-if="item.avatar_url" class="avatar" :src="getAvatarUrl(item.avatar_url)" mode="aspectFill" />
<view v-else class="avatar-placeholder">
<Icon name="user" :size="24" color="#BFB3B3" />
</view>
<text class="nickname">{{ item.nickname || '用户' + item.user_id }}</text>
</view>
<view class="status-badge" :class="item.status">
<text class="status-text">{{ statusMap[item.status] }}</text>
</view>
</view>
<view class="type-tag">
<text class="type-text">{{ typeMap[item.type] }}</text>
</view>
<text class="content">{{ item.content }}</text>
<view v-if="item.contact" class="contact-row">
<text class="contact-label">联系方式</text>
<text class="contact-value" @tap="copyContact(item.contact)">{{ item.contact }}</text>
</view>
<text class="time">{{ formatTime(item.created_at) }}</text>
<!-- 操作按钮 -->
<view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap="updateStatus(item, 'processed')">
<text class="action-text">标记处理</text>
</view>
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap="updateStatus(item, 'ignored')">
<text class="action-text">忽略</text>
</view>
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-if="noMore && list.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
<view v-if="!loading && list.length === 0" class="empty-box">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无反馈</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import { API_BASE } from '@/config'
import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = {
bug: '功能异常',
feature: '功能建议',
ux: '体验问题',
other: '其他'
}
const statusMap: Record<string, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const list = ref<any[]>([])
const loading = ref(false)
const filterStatus = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const initialLoaded = ref(false)
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
loadList(true).finally(() => { initialLoaded.value = true })
})
onShow(() => {
if (initialLoaded.value) loadList(true)
})
function switchStatus(status: string) {
filterStatus.value = status
loadList(true)
}
async function loadList(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
const params: any = { page: page.value, pageSize }
if (filterStatus.value) params.status = filterStatus.value
const data = await getFeedbackList(params)
if (reset) {
list.value = data.list
} else {
list.value = [...list.value, ...data.list]
}
total.value = data.total
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (noMore.value || loading.value) return
page.value++
loadList(false)
}
async function updateStatus(item: any, status: string) {
try {
await updateFeedbackStatus(item.id, status)
item.status = status
uni.showToast({ title: '已更新', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
function copyContact(contact: string) {
uni.setClipboardData({
data: contact,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function goBack() { uni.navigateBack() }
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-md;
}
.nav-back { @include nav-back; }
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder { width: 88rpx; }
.tabs-wrap {
@include tabs-wrap;
margin: 0 0 $space-md;
}
.tabs {
@include tabs;
}
.tab {
@include tab;
font-size: $font-md;
padding: $space-sm $space-md;
}
.list {
height: calc(100vh - 300rpx);
padding: 0 $space-xl;
}
.feedback-card {
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
margin-bottom: $space-md;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.user-info {
display: flex;
align-items: center;
gap: $space-sm;
}
.avatar {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
}
.avatar-placeholder {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
display: flex;
align-items: center;
justify-content: center;
}
.nickname {
font-size: $font-md;
color: $text;
font-weight: 500;
}
.status-badge {
padding: 4rpx 12rpx;
border-radius: $radius-xs;
&.pending { background: #FFF3E0; }
&.processed { background: #E8F5E9; }
&.ignored { background: #F5F5F5; }
}
.status-text {
font-size: $font-xs;
.pending & { color: #F57C00; }
.processed & { color: #43A047; }
.ignored & { color: #9E9E9E; }
}
.type-tag {
display: inline-flex;
padding: 2rpx 12rpx;
background: $primary-light;
border-radius: $radius-xs;
margin-bottom: $space-sm;
}
.type-text {
font-size: $font-xs;
color: $primary;
}
.content {
font-size: $font-lg;
color: $text;
line-height: 1.6;
display: block;
margin-bottom: $space-sm;
}
.contact-row {
display: flex;
align-items: center;
margin-bottom: $space-xs;
}
.contact-label {
font-size: $font-sm;
color: $text-muted;
}
.contact-value {
font-size: $font-sm;
color: $primary;
text-decoration: underline;
}
.time {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-bottom: $space-md;
}
.actions {
display: flex;
gap: $space-sm;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.action-btn {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.process {
background: $primary-light;
border-color: $primary;
}
&.ignore {
background: $bg;
}
&.pending {
background: $bg;
}
&:active { opacity: 0.7; }
}
.action-text {
font-size: $font-md;
.process & { color: $primary; font-weight: 500; }
.ignore & { color: $text-sec; }
.pending & { color: $text-sec; }
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
color: $text-muted;
}
</style>

View File

@@ -72,6 +72,11 @@
<text class="entry-text">公告管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goFeedbackManage">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">反馈管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
</view>
</view>
@@ -114,6 +119,7 @@ onMounted(async () => {
function goBack() { uni.navigateBack() }
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,262 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">意见反馈</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="content">
<!-- 反馈类型 -->
<view class="section">
<text class="label">反馈类型</text>
<view class="type-grid">
<view
v-for="t in types"
:key="t.value"
class="type-item"
:class="{ active: form.type === t.value }"
@tap="form.type = t.value"
>
<text class="type-text">{{ t.label }}</text>
</view>
</view>
</view>
<!-- 反馈内容 -->
<view class="section">
<text class="label">问题描述</text>
<textarea
class="textarea"
v-model="form.content"
placeholder="请详细描述您遇到的问题或建议..."
maxlength="500"
:show-confirm-bar="false"
/>
<text class="char-count">{{ form.content.length }}/500</text>
</view>
<!-- 联系方式 -->
<view class="section">
<text class="label">联系方式选填</text>
<input
class="input"
v-model="form.contact"
placeholder="微信号或邮箱,方便我们联系您"
maxlength="100"
/>
</view>
<!-- 提交按钮 -->
<view class="submit-btn" :class="{ disabled: submitting || !form.content.trim() }" @tap="onSubmit">
<text class="submit-text">{{ submitting ? '提交中...' : '提交反馈' }}</text>
</view>
<text class="tip">感谢您的反馈我们会认真处理每一条意见</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { statusBarHeight } from '@/utils/system'
import { submitFeedback } from '@/api/feedback'
import Icon from '@/components/Icon/Icon.vue'
const types = [
{ label: '功能异常', value: 'bug' },
{ label: '功能建议', value: 'feature' },
{ label: '体验问题', value: 'ux' },
{ label: '其他', value: 'other' }
]
const form = reactive({
type: 'bug',
content: '',
contact: ''
})
const submitting = ref(false)
async function onSubmit() {
if (submitting.value || !form.content.trim()) return
submitting.value = true
try {
await submitFeedback({
type: form.type,
content: form.content.trim(),
contact: form.contact.trim() || undefined
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e: any) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder {
width: 72rpx;
}
.content {
padding: $space-xl;
}
.section {
margin-bottom: $space-2xl;
}
.label {
display: block;
font-size: $font-lg;
font-weight: 500;
color: $text;
margin-bottom: $space-md;
}
.type-grid {
display: flex;
flex-wrap: wrap;
gap: $space-md;
}
.type-item {
padding: $space-sm $space-lg;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.type-text {
font-size: $font-lg;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
.textarea {
width: 100%;
height: 300rpx;
padding: $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
line-height: 1.6;
box-sizing: border-box;
}
.char-count {
display: block;
text-align: right;
font-size: $font-sm;
color: $text-muted;
margin-top: $space-xs;
}
.input {
width: 100%;
height: 96rpx;
padding: 0 $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
box-sizing: border-box;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
margin-top: $space-2xl;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active { opacity: 0.8; }
}
.submit-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.tip {
display: block;
text-align: center;
font-size: $font-md;
color: $text-muted;
margin-top: $space-lg;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">隐私政策</text>
<view class="nav-placeholder"></view>
</view>
</view>
<scroll-view class="content" scroll-y>
<text class="update-date">更新日期2026年6月8日</text>
<text class="section-title">引言</text>
<text class="paragraph">小菜记账以下简称"我们"非常重视用户的隐私保护本隐私政策旨在向您说明我们如何收集使用存储和保护您的个人信息请您仔细阅读</text>
<text class="section-title">我们收集的信息</text>
<text class="paragraph">在您使用本小程序时我们可能收集以下信息</text>
<text class="item">1. 微信 openid用于识别用户身份实现登录功能</text>
<text class="item">2. 用户昵称用于个性化展示您可以自行设置或修改</text>
<text class="item">3. 用户头像用于个性化展示您可以选择上传自定义头像</text>
<text class="item">4. 记账数据包括金额分类备注日期等仅用于为您提供记账服务</text>
<text class="section-title">信息的使用目的</text>
<text class="paragraph">我们收集的信息仅用于以下目的</text>
<text class="item">1. 提供记账统计预算等核心功能服务</text>
<text class="item">2. 实现群组记账功能与您邀请的好友共享账单数据</text>
<text class="item">3. 发送系统公告和重要通知</text>
<text class="item">4. 改善产品功能和用户体验</text>
<text class="section-title">信息的存储与保护</text>
<text class="paragraph">1. 您的数据存储在我们的服务器数据库中采用加密传输和访问控制</text>
<text class="paragraph">2. 我们不会将您的个人信息出售给第三方</text>
<text class="paragraph">3. 除法律法规要求外我们不会向任何第三方披露您的个人信息</text>
<text class="section-title">群组功能说明</text>
<text class="paragraph">1. 群组功能仅用于聚合展示成员的记账数据记录始终属于记录者本人</text>
<text class="paragraph">2. 群组成员可以看到其他成员的记账记录金额分类日期</text>
<text class="paragraph">3. 您可以随时退出群组退出后您的记录将回到个人账本</text>
<text class="section-title">您的权利</text>
<text class="paragraph">您享有以下权利</text>
<text class="item">1. 查看和修改您的个人信息昵称头像</text>
<text class="item">2. 删除您的记账记录</text>
<text class="item">3. 退出或解散群组</text>
<text class="item">4. 导出您的账单数据</text>
<text class="section-title">未成年人保护</text>
<text class="paragraph">如果您是未满 18 周岁的未成年人请在监护人的陪同下阅读本政策并在监护人同意后使用本服务</text>
<text class="section-title">政策更新</text>
<text class="paragraph">我们可能会不时更新本隐私政策更新后的政策将在小程序内公布继续使用本服务即视为您同意更新后的政策</text>
<text class="section-title">联系我们</text>
<text class="paragraph">如您对本隐私政策有任何疑问请通过小程序内的关于小菜联系我们</text>
<view class="bottom-space"></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder {
width: 72rpx;
}
.content {
height: calc(100vh - 200rpx);
padding: $space-xl;
}
.update-date {
display: block;
font-size: $font-md;
color: $text-muted;
margin-bottom: $space-xl;
}
.section-title {
display: block;
font-size: $font-xl;
font-weight: 600;
color: $text;
margin-top: $space-2xl;
margin-bottom: $space-md;
}
.paragraph {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
margin-bottom: $space-sm;
}
.item {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
padding-left: $space-md;
}
.bottom-space {
height: 120rpx;
}
</style>

View File

@@ -58,10 +58,18 @@
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="goFeedback">
<text class="mi-label">意见反馈</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goPrivacy">
<text class="mi-label">隐私政策</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 管理员入口 -->
@@ -193,6 +201,14 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' })
}
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' })
}