feat: 隐私政策 + 意见反馈系统
- 新增隐私政策页面,满足小程序审核要求 - 新增意见反馈功能(用户提交 + 管理员查看) - 后端新增 feedbacks 表和反馈 API - 管理后台新增反馈管理入口 - 我的页面添加意见反馈、隐私政策入口
This commit is contained in:
20
client/src/api/feedback.ts
Normal file
20
client/src/api/feedback.ts
Normal 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 } })
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
418
client/src/pages/admin/feedback.vue
Normal file
418
client/src/pages/admin/feedback.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
262
client/src/pages/feedback/index.vue
Normal file
262
client/src/pages/feedback/index.vue
Normal 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>
|
||||
155
client/src/pages/privacy/index.vue
Normal file
155
client/src/pages/privacy/index.vue
Normal 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>
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -114,3 +114,19 @@ CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
|
||||
content TEXT NOT NULL COMMENT '反馈内容',
|
||||
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
|
||||
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
|
||||
admin_reply TEXT DEFAULT '' COMMENT '管理员回复',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -18,6 +18,7 @@ import groupRoutes from './routes/group'
|
||||
import notificationRoutes from './routes/notification'
|
||||
import filterRoutes from './routes/filter'
|
||||
import adminRoutes from './routes/admin'
|
||||
import feedbackRoutes from './routes/feedback'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -149,6 +150,7 @@ app.use('/api/groups', apiLimiter, groupRoutes)
|
||||
app.use('/api/notifications', apiLimiter, notificationRoutes)
|
||||
app.use('/api/filters', apiLimiter, filterRoutes)
|
||||
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||
app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
|
||||
110
server/src/routes/feedback.ts
Normal file
110
server/src/routes/feedback.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router } from 'express'
|
||||
import { pool } from '../db/connection'
|
||||
import { authMiddleware } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 用户提交反馈 */
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const { type, content, contact } = req.body
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ error: '请填写反馈内容' })
|
||||
}
|
||||
|
||||
const validTypes = ['bug', 'feature', 'ux', 'other']
|
||||
const feedbackType = validTypes.includes(type) ? type : 'other'
|
||||
|
||||
await pool.execute(
|
||||
'INSERT INTO feedbacks (user_id, type, content, contact) VALUES (?, ?, ?, ?)',
|
||||
[userId, feedbackType, content.trim(), contact || '']
|
||||
)
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Submit feedback error:', error)
|
||||
res.status(500).json({ error: '提交失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 管理员获取反馈列表 */
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
|
||||
const user = (users as any[])[0]
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '无权限' })
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1)
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
|
||||
const status = req.query.status as string
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
let where = '1=1'
|
||||
const params: any[] = []
|
||||
|
||||
if (status && ['pending', 'processed', 'ignored'].includes(status)) {
|
||||
where += ' AND f.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
const [countResult] = await pool.execute(
|
||||
`SELECT COUNT(*) as total FROM feedbacks f WHERE ${where}`,
|
||||
params
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT f.*, u.nickname, u.avatar_url
|
||||
FROM feedbacks f
|
||||
LEFT JOIN users u ON f.user_id = u.id
|
||||
WHERE ${where}
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset]
|
||||
)
|
||||
|
||||
res.json({ list: rows, total, page, pageSize })
|
||||
} catch (error) {
|
||||
console.error('Get feedback list error:', error)
|
||||
res.status(500).json({ error: '获取失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 管理员更新反馈状态 */
|
||||
router.put('/:id/status', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
|
||||
const user = (users as any[])[0]
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '无权限' })
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { status, admin_reply } = req.body
|
||||
|
||||
const validStatuses = ['pending', 'processed', 'ignored']
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({ error: '无效的状态' })
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?',
|
||||
[status, admin_reply || '', id]
|
||||
)
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Update feedback status error:', error)
|
||||
res.status(500).json({ error: '更新失败' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user