更新ios

This commit is contained in:
2026-01-22 16:23:45 +08:00
parent 3661b9b4ba
commit fcf98ae277
13 changed files with 1369 additions and 3 deletions

View File

@@ -58,7 +58,7 @@ import MemberList from "../src/screens/Social/MemberList";
import SocialNotifications from "../src/screens/Social/Notifications"; import SocialNotifications from "../src/screens/Social/Notifications";
// 新个人中心页面 // 新个人中心页面
import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile"; import { ProfileScreen as NewProfileScreen, FeedbackScreen } from "../src/screens/Profile";
// 订阅管理页面 // 订阅管理页面
import { SubscriptionScreen } from "../src/screens/Subscription"; import { SubscriptionScreen } from "../src/screens/Subscription";
@@ -460,6 +460,13 @@ function NewProfileStack(props) {
cardStyle: { backgroundColor: "#0F172A" }, cardStyle: { backgroundColor: "#0F172A" },
}} }}
/> />
<Stack.Screen
name="Feedback"
component={FeedbackScreen}
options={{
cardStyle: { backgroundColor: "#0A0A0F" },
}}
/>
</Stack.Navigator> </Stack.Navigator>
); );
} }

View File

@@ -0,0 +1,589 @@
/**
* 意见反馈页面
* 支持提交反馈和查看历史
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
RefreshControl,
Alert,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Input,
TextArea,
Spinner,
useToast,
} from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '../../contexts/AuthContext';
import { feedbackService } from '../../services/feedbackService';
// 反馈类型配置
const FEEDBACK_TYPES = [
{ value: 'bug', label: 'Bug', icon: 'bug', color: '#EF4444' },
{ value: 'suggestion', label: '建议', icon: 'bulb', color: '#F59E0B' },
{ value: 'complaint', label: '投诉', icon: 'warning', color: '#F97316' },
{ value: 'general', label: '其他', icon: 'help-circle', color: '#6B7280' },
];
// 状态配置
const STATUS_CONFIG = {
pending: { label: '待处理', color: '#6B7280' },
processing: { label: '处理中', color: '#3B82F6' },
resolved: { label: '已解决', color: '#10B981' },
closed: { label: '已关闭', color: '#6B7280' },
};
/**
* 标签页选择器
*/
const TabSelector = ({ activeTab, onTabChange }) => {
const tabs = [
{ key: 'submit', label: '提交反馈', icon: 'send' },
{ key: 'history', label: '历史记录', icon: 'time' },
];
return (
<HStack mx={4} bg="rgba(255,255,255,0.05)" borderRadius={12} p={1}>
{tabs.map((tab) => (
<Pressable
key={tab.key}
flex={1}
onPress={() => onTabChange(tab.key)}
>
<Box
py={2.5}
borderRadius={10}
bg={activeTab === tab.key ? 'rgba(124, 58, 237, 0.3)' : 'transparent'}
alignItems="center"
>
<HStack space={2} alignItems="center">
<Icon
as={Ionicons}
name={tab.icon}
size="sm"
color={activeTab === tab.key ? '#A78BFA' : 'gray.500'}
/>
<Text
color={activeTab === tab.key ? 'white' : 'gray.500'}
fontSize={14}
fontWeight={activeTab === tab.key ? 'bold' : 'medium'}
>
{tab.label}
</Text>
</HStack>
</Box>
</Pressable>
))}
</HStack>
);
};
/**
* 类型选择器
*/
const TypeSelector = ({ selectedType, onTypeChange }) => {
return (
<HStack space={2} flexWrap="wrap">
{FEEDBACK_TYPES.map((type) => (
<Pressable
key={type.value}
onPress={() => onTypeChange(type.value)}
mb={2}
>
<Box
px={4}
py={2}
borderRadius={20}
borderWidth={1}
borderColor={selectedType === type.value ? type.color : 'rgba(255,255,255,0.1)'}
bg={selectedType === type.value ? `${type.color}20` : 'transparent'}
>
<HStack space={1.5} alignItems="center">
<Icon
as={Ionicons}
name={type.icon}
size="xs"
color={selectedType === type.value ? type.color : 'gray.500'}
/>
<Text
color={selectedType === type.value ? type.color : 'gray.500'}
fontSize={13}
fontWeight={selectedType === type.value ? 'bold' : 'medium'}
>
{type.label}
</Text>
</HStack>
</Box>
</Pressable>
))}
</HStack>
);
};
/**
* 提交反馈表单
*/
const SubmitFeedbackForm = ({ onSuccess }) => {
const [formData, setFormData] = useState({
type: 'suggestion',
title: '',
content: '',
contact: '',
});
const [submitting, setSubmitting] = useState(false);
const toast = useToast();
const handleSubmit = async () => {
if (!formData.content.trim()) {
toast.show({
description: '请填写反馈内容',
placement: 'top',
bgColor: 'warning.500',
});
return;
}
try {
setSubmitting(true);
await feedbackService.submit({
type: formData.type,
title: formData.title.trim() || undefined,
content: formData.content.trim(),
contact: formData.contact.trim() || undefined,
});
toast.show({
description: '反馈提交成功,感谢您的宝贵意见!',
placement: 'top',
bgColor: 'success.500',
});
// 重置表单
setFormData({
type: 'suggestion',
title: '',
content: '',
contact: '',
});
// 通知刷新历史
if (onSuccess) onSuccess();
} catch (error) {
toast.show({
description: error.message || '提交失败,请稍后重试',
placement: 'top',
bgColor: 'error.500',
});
} finally {
setSubmitting(false);
}
};
return (
<VStack space={5} px={4} mt={4}>
{/* 反馈类型 */}
<Box>
<Text color="gray.400" fontSize={13} mb={2}>
反馈类型
</Text>
<TypeSelector
selectedType={formData.type}
onTypeChange={(type) => setFormData({ ...formData, type })}
/>
</Box>
{/* 标题 */}
<Box>
<Text color="gray.400" fontSize={13} mb={2}>
标题可选
</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData({ ...formData, title: text })}
placeholder="简短描述您的反馈"
placeholderTextColor="gray.600"
bg="rgba(255,255,255,0.05)"
borderColor="rgba(255,255,255,0.1)"
color="white"
fontSize={14}
py={3}
px={4}
borderRadius={12}
maxLength={100}
_focus={{
borderColor: '#7C3AED',
bg: 'rgba(124, 58, 237, 0.1)',
}}
/>
</Box>
{/* 内容 */}
<Box>
<HStack justifyContent="space-between" alignItems="center" mb={2}>
<Text color="gray.400" fontSize={13}>
反馈内容
</Text>
<Text color="gray.600" fontSize={11}>
{formData.content.length}/2000
</Text>
</HStack>
<TextArea
value={formData.content}
onChangeText={(text) => setFormData({ ...formData, content: text })}
placeholder="请详细描述您的问题或建议..."
placeholderTextColor="gray.600"
bg="rgba(255,255,255,0.05)"
borderColor="rgba(255,255,255,0.1)"
color="white"
fontSize={14}
py={3}
px={4}
borderRadius={12}
h={150}
maxLength={2000}
autoCompleteType={undefined}
_focus={{
borderColor: '#7C3AED',
bg: 'rgba(124, 58, 237, 0.1)',
}}
/>
</Box>
{/* 联系方式 */}
<Box>
<Text color="gray.400" fontSize={13} mb={2}>
联系方式可选方便我们沟通
</Text>
<Input
value={formData.contact}
onChangeText={(text) => setFormData({ ...formData, contact: text })}
placeholder="手机号/邮箱/微信"
placeholderTextColor="gray.600"
bg="rgba(255,255,255,0.05)"
borderColor="rgba(255,255,255,0.1)"
color="white"
fontSize={14}
py={3}
px={4}
borderRadius={12}
_focus={{
borderColor: '#7C3AED',
bg: 'rgba(124, 58, 237, 0.1)',
}}
/>
</Box>
{/* 提交按钮 */}
<Pressable onPress={handleSubmit} disabled={submitting}>
<LinearGradient
colors={['#7C3AED', '#EC4899']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.submitButton}
>
{submitting ? (
<Spinner color="white" size="sm" />
) : (
<HStack space={2} alignItems="center">
<Icon as={Ionicons} name="send" size="sm" color="white" />
<Text color="white" fontSize={16} fontWeight="bold">
提交反馈
</Text>
</HStack>
)}
</LinearGradient>
</Pressable>
</VStack>
);
};
/**
* 反馈历史列表
*/
const FeedbackHistoryList = ({ refreshTrigger }) => {
const [feedbacks, setFeedbacks] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadFeedbacks = useCallback(async (pageNum = 1, append = false) => {
try {
if (pageNum === 1) setLoading(true);
const data = await feedbackService.getList({ page: pageNum, perPage: 10 });
if (append) {
setFeedbacks((prev) => [...prev, ...(data.feedbacks || [])]);
} else {
setFeedbacks(data.feedbacks || []);
}
setHasMore(data.pagination?.has_next || false);
} catch (error) {
console.error('加载反馈历史失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadFeedbacks(1);
}, [loadFeedbacks, refreshTrigger]);
const handleRefresh = () => {
setRefreshing(true);
setPage(1);
loadFeedbacks(1);
};
const handleLoadMore = () => {
if (!hasMore || loading) return;
const nextPage = page + 1;
setPage(nextPage);
loadFeedbacks(nextPage, true);
};
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
};
const getTypeConfig = (type) => {
return FEEDBACK_TYPES.find((t) => t.value === type) || FEEDBACK_TYPES[3];
};
if (loading && feedbacks.length === 0) {
return (
<Box flex={1} alignItems="center" justifyContent="center" py={12}>
<Spinner size="lg" color="#7C3AED" />
</Box>
);
}
if (feedbacks.length === 0) {
return (
<Box flex={1} alignItems="center" justifyContent="center" py={12}>
<Icon as={Ionicons} name="time-outline" size="3xl" color="gray.600" mb={3} />
<Text color="gray.500" fontSize={14}>
暂无反馈记录
</Text>
<Text color="gray.600" fontSize={12} mt={1}>
提交反馈后会在这里显示
</Text>
</Box>
);
}
return (
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
onScrollEndDrag={handleLoadMore}
>
<VStack space={3} px={4} mt={4} pb={20}>
{feedbacks.map((feedback) => {
const typeConfig = getTypeConfig(feedback.feedback_type);
const statusConfig = STATUS_CONFIG[feedback.status] || STATUS_CONFIG.pending;
return (
<Box
key={feedback.id}
bg="rgba(255,255,255,0.03)"
borderWidth={1}
borderColor="rgba(255,255,255,0.06)"
borderRadius={16}
p={4}
>
<VStack space={3}>
{/* 头部 */}
<HStack justifyContent="space-between" alignItems="center">
<HStack space={2} alignItems="center">
<Box
px={2}
py={0.5}
borderRadius={6}
bg={`${typeConfig.color}20`}
>
<HStack space={1} alignItems="center">
<Icon
as={Ionicons}
name={typeConfig.icon}
size="2xs"
color={typeConfig.color}
/>
<Text color={typeConfig.color} fontSize={11} fontWeight="bold">
{typeConfig.label}
</Text>
</HStack>
</Box>
<Box
px={2}
py={0.5}
borderRadius={6}
bg={`${statusConfig.color}20`}
>
<Text color={statusConfig.color} fontSize={11} fontWeight="medium">
{statusConfig.label}
</Text>
</Box>
</HStack>
<Text color="gray.600" fontSize={11}>
{formatDateTime(feedback.created_at)}
</Text>
</HStack>
{/* 标题 */}
{feedback.title && (
<Text color="white" fontSize={14} fontWeight="bold">
{feedback.title}
</Text>
)}
{/* 内容 */}
<Text color="gray.400" fontSize={13} numberOfLines={3}>
{feedback.content}
</Text>
{/* 管理员回复 */}
{feedback.admin_reply && (
<Box
bg="rgba(59, 130, 246, 0.1)"
borderLeftWidth={3}
borderLeftColor="#3B82F6"
p={3}
borderRadius={8}
mt={1}
>
<HStack space={1.5} alignItems="center" mb={1.5}>
<Icon as={Ionicons} name="chatbubble" size="xs" color="#3B82F6" />
<Text color="#60A5FA" fontSize={12} fontWeight="bold">
官方回复
</Text>
</HStack>
<Text color="gray.300" fontSize={12}>
{feedback.admin_reply}
</Text>
</Box>
)}
</VStack>
</Box>
);
})}
{/* 加载更多 */}
{hasMore && (
<Pressable onPress={handleLoadMore}>
<Box py={3} alignItems="center">
<Text color="gray.500" fontSize={13}>
加载更多
</Text>
</Box>
</Pressable>
)}
</VStack>
</ScrollView>
);
};
/**
* 意见反馈主页面
*/
const FeedbackScreen = () => {
const navigation = useNavigation();
const [activeTab, setActiveTab] = useState('submit');
const [refreshTrigger, setRefreshTrigger] = useState(0);
const handleSubmitSuccess = () => {
setRefreshTrigger((prev) => prev + 1);
};
return (
<Box flex={1} bg="#0A0A0F">
<SafeAreaView style={styles.container} edges={['top']}>
{/* 背景渐变 */}
<LinearGradient
colors={['rgba(124, 58, 237, 0.1)', 'transparent']}
style={styles.headerGradient}
/>
{/* 头部导航 */}
<HStack px={4} py={3} alignItems="center">
<Pressable onPress={() => navigation.goBack()} hitSlop={10}>
<Box
bg="rgba(255,255,255,0.05)"
p={2}
borderRadius={10}
>
<Icon as={Ionicons} name="arrow-back" size="sm" color="white" />
</Box>
</Pressable>
<Text color="white" fontSize={18} fontWeight="bold" ml={3}>
意见反馈
</Text>
</HStack>
{/* 标签页选择器 */}
<TabSelector activeTab={activeTab} onTabChange={setActiveTab} />
{/* 内容区域 */}
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{activeTab === 'submit' ? (
<ScrollView showsVerticalScrollIndicator={false}>
<SubmitFeedbackForm onSuccess={handleSubmitSuccess} />
<Box height={100} />
</ScrollView>
) : (
<FeedbackHistoryList refreshTrigger={refreshTrigger} />
)}
</KeyboardAvoidingView>
</SafeAreaView>
</Box>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerGradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 200,
},
submitButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
});
export default FeedbackScreen;

View File

@@ -358,6 +358,8 @@ const ProfileScreen = () => {
navigation.navigate('Subscription'); navigation.navigate('Subscription');
} else if (item.route === 'Messages') { } else if (item.route === 'Messages') {
navigation.navigate('Messages'); navigation.navigate('Messages');
} else if (item.route === 'Feedback') {
navigation.navigate('Feedback');
} else if (item.route === 'Settings') { } else if (item.route === 'Settings') {
navigation.navigate('SettingsDrawer'); navigation.navigate('SettingsDrawer');
} else if (item.route === 'About') { } else if (item.route === 'About') {

View File

@@ -3,3 +3,4 @@
*/ */
export { default as ProfileScreen } from './ProfileScreen'; export { default as ProfileScreen } from './ProfileScreen';
export { default as FeedbackScreen } from './FeedbackScreen';

View File

@@ -0,0 +1,65 @@
/**
* 意见反馈服务
* 提交和查询用户反馈
*/
import { apiRequest } from './api';
/**
* 提交反馈
* @param {object} data - 反馈数据
* @param {string} data.type - 反馈类型 (general/bug/suggestion/complaint)
* @param {string} data.title - 标题(可选)
* @param {string} data.content - 内容(必填)
* @param {string} data.contact - 联系方式(可选)
* @param {string[]} data.images - 图片URL列表可选
*/
const submit = async (data) => {
const response = await apiRequest('/api/user/feedback', {
method: 'POST',
body: JSON.stringify(data),
});
if (response.code !== 200) {
throw new Error(response.message || '提交反馈失败');
}
return response.data;
};
/**
* 获取反馈历史列表
* @param {object} options - 分页选项
* @param {number} options.page - 页码
* @param {number} options.perPage - 每页数量
* @param {string} options.status - 筛选状态(可选)
*/
const getList = async (options = {}) => {
const { page = 1, perPage = 10, status = '' } = options;
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (status) {
params.append('status', status);
}
const response = await apiRequest(`/api/user/feedback?${params}`, {
method: 'GET',
});
if (response.code !== 200) {
throw new Error(response.message || '获取反馈历史失败');
}
return response.data;
};
export const feedbackService = {
submit,
getList,
};
export default feedbackService;

143
app.py
View File

@@ -1677,6 +1677,41 @@ class AnnouncementReadStatus(db.Model):
announcement = db.relationship('Announcement', backref='read_statuses') announcement = db.relationship('Announcement', backref='read_statuses')
class Feedback(db.Model):
"""用户反馈表"""
__tablename__ = 'user_feedback'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True, index=True)
feedback_type = db.Column(db.String(50), default='general') # general/bug/suggestion/complaint
title = db.Column(db.String(200))
content = db.Column(db.Text, nullable=False)
contact = db.Column(db.String(100)) # 联系方式(可选,默认用户手机/邮箱)
images = db.Column(db.JSON) # 图片URL列表
status = db.Column(db.String(20), default='pending') # pending/processing/resolved/closed
admin_reply = db.Column(db.Text) # 管理员回复
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# 关系
user = db.relationship('User', backref='feedbacks')
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'feedback_type': self.feedback_type,
'title': self.title,
'content': self.content,
'contact': self.contact,
'images': self.images or [],
'status': self.status,
'admin_reply': self.admin_reply,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class SubscriptionPlan(db.Model): class SubscriptionPlan(db.Model):
"""订阅套餐表""" """订阅套餐表"""
__tablename__ = 'subscription_plans' __tablename__ = 'subscription_plans'
@@ -21954,6 +21989,114 @@ def delete_announcement(announcement_id):
return jsonify({'code': 500, 'message': str(e)}), 500 return jsonify({'code': 500, 'message': str(e)}), 500
# ==================== 意见反馈 API ====================
@app.route('/api/user/feedback', methods=['POST'])
@login_required
def submit_feedback():
"""
提交用户反馈
请求体:
{
"type": "bug", // general/bug/suggestion/complaint
"title": "反馈标题", // 可选
"content": "反馈内容", // 必填
"contact": "联系方式", // 可选,默认取用户手机/邮箱
"images": ["url1", "url2"] // 可选
}
"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 400, 'message': '请求数据无效'}), 400
content = data.get('content', '').strip()
if not content:
return jsonify({'code': 400, 'message': '反馈内容不能为空'}), 400
# 获取联系方式(优先使用用户提供的,否则使用用户的手机/邮箱)
contact = data.get('contact', '').strip()
if not contact:
contact = current_user.phone or current_user.email or ''
feedback = Feedback(
user_id=current_user.id,
feedback_type=data.get('type', 'general'),
title=data.get('title', '').strip() or None,
content=content,
contact=contact,
images=data.get('images') or None,
status='pending'
)
db.session.add(feedback)
db.session.commit()
return jsonify({
'code': 200,
'message': '反馈提交成功,感谢您的宝贵意见!',
'data': {
'feedback_id': feedback.id,
'status': feedback.status,
'created_at': feedback.created_at.isoformat() if feedback.created_at else None
}
})
except Exception as e:
db.session.rollback()
app.logger.error(f"提交反馈失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/user/feedback', methods=['GET'])
@login_required
def get_my_feedback():
"""
获取当前用户的反馈历史
查询参数:
- page: 页码默认1
- per_page: 每页数量默认10
- status: 筛选状态(可选)
"""
try:
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 50)
status_filter = request.args.get('status', '').strip()
query = db.session.query(Feedback).filter(
Feedback.user_id == current_user.id
)
if status_filter:
query = query.filter(Feedback.status == status_filter)
# 按创建时间倒序
query = query.order_by(desc(Feedback.created_at))
# 分页
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
feedbacks = pagination.items
return jsonify({
'code': 200,
'data': {
'feedbacks': [f.to_dict() for f in feedbacks],
'pagination': {
'page': pagination.page,
'per_page': pagination.per_page,
'total': pagination.total,
'pages': pagination.pages,
'has_next': pagination.has_next,
'has_prev': pagination.has_prev
}
}
})
except Exception as e:
app.logger.error(f"获取反馈历史失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
# 创建数据库表 # 创建数据库表
with app.app_context(): with app.app_context():

View File

@@ -344,6 +344,21 @@ const MobileDrawer = memo(({
</HStack> </HStack>
</Box> </Box>
{/* 意见反馈 */}
{isAuthenticated && user && (
<>
<Divider />
<Button
colorScheme="blue"
variant="ghost"
size="sm"
onClick={() => handleNavigate('/feedback')}
>
意见反馈
</Button>
</>
)}
{/* 移动端登录/登出按钮 */} {/* 移动端登录/登出按钮 */}
<Divider /> <Divider />
{isAuthenticated && user ? ( {isAuthenticated && user ? (

View File

@@ -15,7 +15,7 @@ import {
Button, Button,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Settings, LogOut, Bell } from 'lucide-react'; import { Settings, LogOut, Bell, MessageSquare } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar'; import UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription'; import { useSubscription } from '../../../../hooks/useSubscription';
@@ -221,6 +221,13 @@ const DesktopUserMenu = memo(({ user, handleLogout }) => {
> >
站内信 站内信
</MenuItem> </MenuItem>
<MenuItem
icon={<MessageSquare size={16} />}
onClick={() => { onClose(); navigate('/feedback'); }}
py={3}
>
意见反馈
</MenuItem>
<MenuItem <MenuItem
icon={<Settings size={16} />} icon={<Settings size={16} />}
onClick={handleNavigateToSettings} onClick={handleNavigateToSettings}

View File

@@ -14,7 +14,7 @@ import {
Badge, Badge,
useColorModeValue useColorModeValue
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Star, Calendar, User, Settings, Home, LogOut, Crown, Bell } from 'lucide-react'; import { Star, Calendar, User, Settings, Home, LogOut, Crown, Bell, MessageSquare } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar'; import UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription'; import { useSubscription } from '../../../../hooks/useSubscription';
@@ -114,6 +114,9 @@ const TabletUserMenu = memo(({
<MenuItem icon={<Bell size={16} />} onClick={() => navigate('/inbox')}> <MenuItem icon={<Bell size={16} />} onClick={() => navigate('/inbox')}>
站内信 站内信
</MenuItem> </MenuItem>
<MenuItem icon={<MessageSquare size={16} />} onClick={() => navigate('/feedback')}>
意见反馈
</MenuItem>
<MenuItem icon={<User size={16} />} onClick={() => navigate('/home/profile')}> <MenuItem icon={<User size={16} />} onClick={() => navigate('/home/profile')}>
个人资料 个人资料
</MenuItem> </MenuItem>

View File

@@ -59,6 +59,9 @@ export const lazyComponents = {
// 站内信模块 // 站内信模块
Inbox: React.lazy(() => import('@views/Inbox')), Inbox: React.lazy(() => import('@views/Inbox')),
// 意见反馈模块
Feedback: React.lazy(() => import('@views/Feedback')),
}; };
/** /**
@@ -92,4 +95,5 @@ export const {
StockCommunity, StockCommunity,
DataBrowser, DataBrowser,
Inbox, Inbox,
Feedback,
} = lazyComponents; } = lazyComponents;

View File

@@ -249,6 +249,18 @@ export const routeConfig = [
description: '官方公告和互动消息' description: '官方公告和互动消息'
} }
}, },
// ==================== 意见反馈模块 ====================
{
path: 'feedback',
component: lazyComponents.Feedback,
protection: PROTECTION_MODES.REDIRECT, // 需要登录才能提交反馈
layout: 'main',
meta: {
title: '意见反馈',
description: '提交建议和问题反馈'
}
},
]; ];
/** /**

View File

@@ -0,0 +1,74 @@
/**
* 意见反馈服务
* 提交和查询用户反馈
*/
import { getApiBase } from './api';
const API_BASE = getApiBase();
/**
* 提交反馈
* @param {object} data - 反馈数据
* @param {string} data.type - 反馈类型 (general/bug/suggestion/complaint)
* @param {string} data.title - 标题(可选)
* @param {string} data.content - 内容(必填)
* @param {string} data.contact - 联系方式(可选)
* @param {string[]} data.images - 图片URL列表可选
*/
export const submitFeedback = async (data) => {
const response = await fetch(`${API_BASE}/api/user/feedback`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message || '提交反馈失败');
}
return result.data;
};
/**
* 获取反馈历史列表
* @param {object} options - 分页选项
* @param {number} options.page - 页码
* @param {number} options.perPage - 每页数量
* @param {string} options.status - 筛选状态(可选)
*/
export const getFeedbackList = async (options = {}) => {
const { page = 1, perPage = 10, status = '' } = options;
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (status) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}/api/user/feedback?${params}`, {
method: 'GET',
credentials: 'include',
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message || '获取反馈历史失败');
}
return result.data;
};
export default {
submitFeedback,
getFeedbackList,
};

444
src/views/Feedback/index.js Normal file
View File

@@ -0,0 +1,444 @@
/**
* 意见反馈页面
* 支持提交反馈和查看历史
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Badge,
Card,
CardBody,
Icon,
Spinner,
Center,
Button,
useColorModeValue,
FormControl,
FormLabel,
Input,
Textarea,
Select,
useToast,
Alert,
AlertIcon,
Divider,
} from '@chakra-ui/react';
import {
MessageSquare,
Send,
History,
Bug,
Lightbulb,
AlertTriangle,
HelpCircle,
Clock,
CheckCircle,
XCircle,
MessageCircle,
} from 'lucide-react';
import { submitFeedback, getFeedbackList } from '../../services/feedbackService';
// 反馈类型配置
const FEEDBACK_TYPES = [
{ value: 'bug', label: 'Bug 报告', icon: Bug, color: 'red' },
{ value: 'suggestion', label: '功能建议', icon: Lightbulb, color: 'yellow' },
{ value: 'complaint', label: '问题投诉', icon: AlertTriangle, color: 'orange' },
{ value: 'general', label: '其他反馈', icon: HelpCircle, color: 'gray' },
];
// 状态配置
const STATUS_CONFIG = {
pending: { label: '待处理', color: 'gray', icon: Clock },
processing: { label: '处理中', color: 'blue', icon: MessageCircle },
resolved: { label: '已解决', color: 'green', icon: CheckCircle },
closed: { label: '已关闭', color: 'gray', icon: XCircle },
};
// 格式化时间
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 提交反馈表单组件
const SubmitFeedbackForm = ({ onSuccess }) => {
const [formData, setFormData] = useState({
type: 'suggestion',
title: '',
content: '',
contact: '',
});
const [submitting, setSubmitting] = useState(false);
const toast = useToast();
const inputBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.content.trim()) {
toast({
title: '请填写反馈内容',
status: 'warning',
duration: 2000,
});
return;
}
try {
setSubmitting(true);
await submitFeedback({
type: formData.type,
title: formData.title.trim() || undefined,
content: formData.content.trim(),
contact: formData.contact.trim() || undefined,
});
toast({
title: '反馈提交成功',
description: '感谢您的宝贵意见,我们会尽快处理!',
status: 'success',
duration: 3000,
});
// 重置表单
setFormData({
type: 'suggestion',
title: '',
content: '',
contact: '',
});
// 通知父组件刷新历史
if (onSuccess) onSuccess();
} catch (error) {
toast({
title: '提交失败',
description: error.message,
status: 'error',
duration: 3000,
});
} finally {
setSubmitting(false);
}
};
return (
<Box as="form" onSubmit={handleSubmit}>
<VStack spacing={5} align="stretch">
{/* 反馈类型 */}
<FormControl>
<FormLabel fontWeight="medium">反馈类型</FormLabel>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
bg={inputBg}
borderColor={borderColor}
>
{FEEDBACK_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</Select>
</FormControl>
{/* 标题(可选) */}
<FormControl>
<FormLabel fontWeight="medium">
标题
<Text as="span" color="gray.500" fontWeight="normal" ml={2} fontSize="sm">
可选
</Text>
</FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="简短描述您的反馈"
bg={inputBg}
borderColor={borderColor}
maxLength={100}
/>
</FormControl>
{/* 反馈内容 */}
<FormControl isRequired>
<FormLabel fontWeight="medium">反馈内容</FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="请详细描述您的问题或建议..."
bg={inputBg}
borderColor={borderColor}
rows={6}
maxLength={2000}
/>
<Text fontSize="xs" color="gray.500" textAlign="right" mt={1}>
{formData.content.length}/2000
</Text>
</FormControl>
{/* 联系方式(可选) */}
<FormControl>
<FormLabel fontWeight="medium">
联系方式
<Text as="span" color="gray.500" fontWeight="normal" ml={2} fontSize="sm">
可选方便我们与您沟通
</Text>
</FormLabel>
<Input
value={formData.contact}
onChange={(e) => setFormData({ ...formData, contact: e.target.value })}
placeholder="手机号/邮箱/微信"
bg={inputBg}
borderColor={borderColor}
/>
</FormControl>
{/* 提交按钮 */}
<Button
type="submit"
colorScheme="blue"
size="lg"
leftIcon={<Send size={18} />}
isLoading={submitting}
loadingText="提交中..."
>
提交反馈
</Button>
</VStack>
</Box>
);
};
// 反馈历史列表组件
const FeedbackHistoryList = ({ refreshTrigger }) => {
const [feedbacks, setFeedbacks] = useState([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.100', 'gray.700');
const replyBg = useColorModeValue('blue.50', 'rgba(66, 153, 225, 0.1)');
const loadFeedbacks = useCallback(async (pageNum = 1, append = false) => {
try {
setLoading(true);
const data = await getFeedbackList({ page: pageNum, perPage: 10 });
if (append) {
setFeedbacks((prev) => [...prev, ...(data.feedbacks || [])]);
} else {
setFeedbacks(data.feedbacks || []);
}
setHasMore(data.pagination?.has_next || false);
} catch (error) {
console.error('加载反馈历史失败:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadFeedbacks(1);
}, [loadFeedbacks, refreshTrigger]);
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
loadFeedbacks(nextPage, true);
};
const getTypeConfig = (type) => {
return FEEDBACK_TYPES.find((t) => t.value === type) || FEEDBACK_TYPES[3];
};
if (loading && feedbacks.length === 0) {
return (
<Center py={12}>
<Spinner size="lg" color="blue.500" />
</Center>
);
}
if (feedbacks.length === 0) {
return (
<Center py={12} flexDirection="column">
<Icon as={History} boxSize={12} color="gray.400" mb={4} />
<Text color="gray.500">暂无反馈记录</Text>
<Text color="gray.400" fontSize="sm" mt={1}>
提交反馈后会在这里显示
</Text>
</Center>
);
}
return (
<VStack spacing={4} align="stretch">
{feedbacks.map((feedback) => {
const typeConfig = getTypeConfig(feedback.feedback_type);
const statusConfig = STATUS_CONFIG[feedback.status] || STATUS_CONFIG.pending;
const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<Card
key={feedback.id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
shadow="sm"
>
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部:类型 + 状态 + 时间 */}
<HStack justify="space-between" wrap="wrap">
<HStack spacing={2}>
<Icon as={TypeIcon} color={`${typeConfig.color}.500`} boxSize={4} />
<Badge colorScheme={typeConfig.color}>{typeConfig.label}</Badge>
{feedback.title && (
<Text fontWeight="semibold" noOfLines={1}>
{feedback.title}
</Text>
)}
</HStack>
<HStack spacing={2}>
<Badge colorScheme={statusConfig.color} variant="subtle">
<HStack spacing={1}>
<Icon as={StatusIcon} boxSize={3} />
<Text>{statusConfig.label}</Text>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
{formatDateTime(feedback.created_at)}
</Text>
</HStack>
</HStack>
{/* 反馈内容 */}
<Text color="gray.700" fontSize="sm" whiteSpace="pre-wrap">
{feedback.content}
</Text>
{/* 管理员回复 */}
{feedback.admin_reply && (
<>
<Divider />
<Box bg={replyBg} p={3} borderRadius="md" borderLeft="3px solid" borderColor="blue.400">
<HStack spacing={2} mb={2}>
<Icon as={MessageCircle} color="blue.500" boxSize={4} />
<Text fontWeight="medium" fontSize="sm" color="blue.600">
官方回复
</Text>
{feedback.updated_at && (
<Text fontSize="xs" color="gray.500">
{formatDateTime(feedback.updated_at)}
</Text>
)}
</HStack>
<Text fontSize="sm" color="gray.700" whiteSpace="pre-wrap">
{feedback.admin_reply}
</Text>
</Box>
</>
)}
</VStack>
</CardBody>
</Card>
);
})}
{/* 加载更多 */}
{hasMore && (
<Center pt={2}>
<Button variant="ghost" size="sm" onClick={handleLoadMore} isLoading={loading}>
加载更多
</Button>
</Center>
)}
</VStack>
);
};
// 主页面组件
const FeedbackPage = () => {
const [refreshTrigger, setRefreshTrigger] = useState(0);
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const handleSubmitSuccess = () => {
setRefreshTrigger((prev) => prev + 1);
};
return (
<Box minH="100vh" bg={bgColor} py={8}>
<Box maxW="800px" mx="auto" px={4}>
{/* 页面标题 */}
<HStack mb={6}>
<Icon as={MessageSquare} boxSize={6} color="blue.500" />
<Heading size="lg">意见反馈</Heading>
</HStack>
{/* 提示信息 */}
<Alert status="info" borderRadius="md" mb={6}>
<AlertIcon />
<Text fontSize="sm">
您的反馈对我们非常重要我们会认真阅读每一条建议并尽快改进
</Text>
</Alert>
{/* 标签页 */}
<Card bg={cardBg} shadow="sm">
<CardBody p={0}>
<Tabs variant="enclosed" colorScheme="blue">
<TabList borderBottomWidth="1px" px={4} pt={4}>
<Tab fontWeight="medium">
<HStack spacing={2}>
<Icon as={Send} boxSize={4} />
<Text>提交反馈</Text>
</HStack>
</Tab>
<Tab fontWeight="medium">
<HStack spacing={2}>
<Icon as={History} boxSize={4} />
<Text>反馈历史</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
<TabPanel p={6}>
<SubmitFeedbackForm onSuccess={handleSubmitSuccess} />
</TabPanel>
<TabPanel p={6}>
<FeedbackHistoryList refreshTrigger={refreshTrigger} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</Box>
</Box>
);
};
export default FeedbackPage;