更新ios

This commit is contained in:
2026-01-22 15:48:33 +08:00
parent 82e543d830
commit cd90769cab
7 changed files with 790 additions and 68 deletions

View File

@@ -55,6 +55,7 @@ import PostDetail from "../src/screens/Social/PostDetail";
import CreatePost from "../src/screens/Social/CreatePost";
import CreateChannel from "../src/screens/Social/CreateChannel";
import MemberList from "../src/screens/Social/MemberList";
import SocialNotifications from "../src/screens/Social/Notifications";
// 新个人中心页面
import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile";
@@ -452,6 +453,13 @@ function NewProfileStack(props) {
cardStyle: { backgroundColor: "#0A0A0F" },
}}
/>
<Stack.Screen
name="Messages"
component={SocialNotifications}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
</Stack.Navigator>
);
}

View File

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

View File

@@ -1,9 +1,9 @@
/**
* 通知页面
* 显示社区通知列表
* 显示社区通知和官方公告
*/
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
FlatList,
RefreshControl,
@@ -28,10 +28,18 @@ import {
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
fetchAnnouncements,
markAnnouncementRead,
markAllAnnouncementsRead,
} from '../../store/slices/socialSlice';
// 通知类型配置
const NOTIFICATION_CONFIG = {
announcement: {
icon: 'megaphone',
color: '#D4AF37',
label: '官方公告',
},
reply: {
icon: 'chatbubble',
color: '#7C3AED',
@@ -52,15 +60,21 @@ const NOTIFICATION_CONFIG = {
color: '#3B82F6',
label: '关注了你',
},
message: {
icon: 'chatbubble-ellipses',
color: '#06B6D4',
label: '发来消息',
},
system: {
icon: 'megaphone',
color: '#D4AF37',
icon: 'information-circle',
color: '#6B7280',
label: '系统通知',
},
};
// 格式化相对时间
const formatRelativeTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
@@ -83,73 +97,147 @@ const Notifications = ({ navigation }) => {
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const communityState = useSelector((state) => state.social);
const notifications = communityState?.notifications || [];
const notificationsHasMore = communityState?.notificationsHasMore ?? true;
const loading = communityState?.loading || {};
const unreadCount = communityState?.unreadCount || 0;
const socialState = useSelector((state) => state.social);
const notifications = socialState?.notifications || [];
const announcements = socialState?.announcements || [];
const notificationsHasMore = socialState?.notificationsHasMore ?? true;
const announcementsHasMore = socialState?.announcementsHasMore ?? true;
const loading = socialState?.loading || {};
const notificationUnreadCount = socialState?.unreadCount || 0;
const announcementUnreadCount = socialState?.announcementUnreadCount || 0;
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [notificationPage, setNotificationPage] = useState(1);
const [announcementPage, setAnnouncementPage] = useState(1);
// 加载通知
// 合并通知和公告,按时间排序
const mergedItems = useMemo(() => {
// 将公告转换为统一格式
const formattedAnnouncements = announcements.map(a => ({
id: `announcement-${a.id}`,
originalId: a.id,
type: 'announcement',
isAnnouncement: true,
title: a.title,
content: a.content,
targetUrl: a.targetUrl,
isRead: a.isRead,
createdAt: a.createdAt,
}));
// 合并并按时间排序
const merged = [...notifications, ...formattedAnnouncements];
merged.sort((a, b) => {
const dateA = new Date(a.createdAt || 0);
const dateB = new Date(b.createdAt || 0);
return dateB - dateA;
});
return merged;
}, [notifications, announcements]);
// 总未读数
const totalUnreadCount = notificationUnreadCount + announcementUnreadCount;
// 加载数据
useEffect(() => {
dispatch(fetchNotifications({ page: 1 }));
setPage(1);
dispatch(fetchAnnouncements({ page: 1 }));
setNotificationPage(1);
setAnnouncementPage(1);
}, [dispatch]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await dispatch(fetchNotifications({ page: 1 }));
setPage(1);
await Promise.all([
dispatch(fetchNotifications({ page: 1 })),
dispatch(fetchAnnouncements({ page: 1 })),
]);
setNotificationPage(1);
setAnnouncementPage(1);
setRefreshing(false);
}, [dispatch]);
// 加载更多
const handleLoadMore = useCallback(() => {
if (loading.notifications || !notificationsHasMore) return;
const isLoadingAny = loading.notifications || loading.announcements;
if (isLoadingAny) return;
const nextPage = page + 1;
// 优先加载有更多数据的类型
if (notificationsHasMore) {
const nextPage = notificationPage + 1;
dispatch(fetchNotifications({ page: nextPage }));
setPage(nextPage);
}, [dispatch, page, loading.notifications, notificationsHasMore]);
setNotificationPage(nextPage);
}
if (announcementsHasMore) {
const nextPage = announcementPage + 1;
dispatch(fetchAnnouncements({ page: nextPage }));
setAnnouncementPage(nextPage);
}
}, [dispatch, notificationPage, announcementPage, loading, notificationsHasMore, announcementsHasMore]);
// 标记已读
const handleMarkRead = useCallback((notification) => {
if (!notification.isRead) {
dispatch(markNotificationRead(notification.id));
const handleMarkRead = useCallback((item) => {
if (item.isAnnouncement) {
// 公告
if (!item.isRead) {
dispatch(markAnnouncementRead(item.originalId));
}
// 如果有跳转链接,可以跳转
if (item.targetUrl) {
// TODO: 处理 URL 跳转
console.log('跳转到:', item.targetUrl);
}
} else {
// 普通通知
if (!item.isRead) {
dispatch(markNotificationRead(item.id));
}
// 根据通知类型跳转
if (notification.targetType === 'post' && notification.targetId) {
if (item.targetType === 'post' && item.targetId) {
navigation.navigate('PostDetail', {
post: { id: notification.targetId },
post: { id: item.targetId },
channel: {},
});
}
}
}, [dispatch, navigation]);
// 标记全部已读
const handleMarkAllRead = useCallback(() => {
if (notificationUnreadCount > 0) {
dispatch(markAllNotificationsRead());
}, [dispatch]);
}
if (announcementUnreadCount > 0) {
dispatch(markAllAnnouncementsRead());
}
}, [dispatch, notificationUnreadCount, announcementUnreadCount]);
// 检查是否可以返回(从个人中心进入时需要返回按钮)
const canGoBack = navigation.canGoBack();
// 渲染头部
const renderHeader = () => (
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
<HStack alignItems="center" space={2}>
{canGoBack && (
<Pressable onPress={() => navigation.goBack()} mr={2}>
<Icon as={Ionicons} name="chevron-back" size="md" color="white" />
</Pressable>
)}
<Text fontSize="md" fontWeight="bold" color="white">
通知
消息中心
</Text>
{unreadCount > 0 ? (
{totalUnreadCount > 0 ? (
<Box bg="red.500" rounded="full" px={2} py={0.5}>
<Text fontSize="2xs" fontWeight="bold" color="white">
{unreadCount > 99 ? '99+' : String(unreadCount)}
{totalUnreadCount > 99 ? '99+' : String(totalUnreadCount)}
</Text>
</Box>
) : null}
</HStack>
{unreadCount > 0 ? (
{totalUnreadCount > 0 ? (
<Pressable onPress={handleMarkAllRead}>
<Text fontSize="xs" color="primary.400">
全部已读
@@ -159,14 +247,15 @@ const Notifications = ({ navigation }) => {
</HStack>
);
// 渲染通知项
const renderNotificationItem = ({ item: notification }) => {
if (!notification) return null;
// 渲染通知/公告
const renderItem = ({ item }) => {
if (!item) return null;
const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system;
const isAnnouncement = item.isAnnouncement;
const config = NOTIFICATION_CONFIG[item.type] || NOTIFICATION_CONFIG.system;
return (
<Pressable onPress={() => handleMarkRead(notification)}>
<Pressable onPress={() => handleMarkRead(item)}>
{({ isPressed }) => (
<HStack
mx={4}
@@ -178,22 +267,24 @@ const Notifications = ({ navigation }) => {
bg={
isPressed
? 'rgba(255, 255, 255, 0.08)'
: notification.isRead
: item.isRead
? 'transparent'
: isAnnouncement
? 'rgba(212, 175, 55, 0.05)'
: 'rgba(124, 58, 237, 0.05)'
}
borderWidth={notification.isRead ? 0 : 1}
borderColor="rgba(124, 58, 237, 0.1)"
borderWidth={item.isRead ? 0 : 1}
borderColor={isAnnouncement ? 'rgba(212, 175, 55, 0.15)' : 'rgba(124, 58, 237, 0.1)'}
>
{/* 图标/头像 */}
<Box position="relative">
{notification.senderAvatar ? (
{!isAnnouncement && item.senderAvatar ? (
<Avatar
size="sm"
source={{ uri: notification.senderAvatar }}
source={{ uri: item.senderAvatar }}
bg="primary.600"
>
{notification.senderName?.[0]?.toUpperCase() || '?'}
{item.senderName?.[0]?.toUpperCase() || '?'}
</Avatar>
) : (
<Box
@@ -208,7 +299,7 @@ const Notifications = ({ navigation }) => {
</Box>
)}
{/* 未读标记 */}
{!notification.isRead && (
{!item.isRead && (
<Box
position="absolute"
top={-2}
@@ -216,7 +307,7 @@ const Notifications = ({ navigation }) => {
w={3}
h={3}
rounded="full"
bg="primary.500"
bg={isAnnouncement ? '#D4AF37' : 'primary.500'}
/>
)}
</Box>
@@ -224,29 +315,44 @@ const Notifications = ({ navigation }) => {
{/* 内容 */}
<VStack flex={1} ml={3}>
<HStack alignItems="center" flexWrap="wrap">
{notification.senderName && (
{isAnnouncement ? (
<HStack alignItems="center" space={1}>
<Text fontSize="sm" fontWeight="bold" color="#D4AF37">
{config.label}
</Text>
{item.title && (
<Text fontSize="sm" fontWeight="bold" color="white" numberOfLines={1}>
· {item.title}
</Text>
)}
</HStack>
) : (
<>
{item.senderName && (
<Text fontSize="sm" fontWeight="bold" color="white" mr={1}>
{notification.senderName}
{item.senderName}
</Text>
)}
<Text fontSize="sm" color="gray.400">
{config.label}
</Text>
</>
)}
</HStack>
{notification.content && (
{item.content && (
<Text
fontSize="sm"
color="gray.300"
numberOfLines={2}
mt={1}
>
{notification.content}
{item.content}
</Text>
)}
<Text fontSize="2xs" color="gray.500" mt={1}>
{formatRelativeTime(notification.createdAt)}
{formatRelativeTime(item.createdAt)}
</Text>
</VStack>
@@ -260,12 +366,12 @@ const Notifications = ({ navigation }) => {
// 渲染空状态
const renderEmpty = () => {
if (loading.notifications) {
if (loading.notifications || loading.announcements) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载通知...
加载消息...
</Text>
</Center>
);
@@ -275,7 +381,7 @@ const Notifications = ({ navigation }) => {
<Center flex={1} py={20}>
<Icon as={Ionicons} name="notifications-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
暂无通知
暂无消息
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
当有人回复或提及你时会收到通知
@@ -286,9 +392,10 @@ const Notifications = ({ navigation }) => {
// 渲染加载更多
const renderFooter = () => {
if (!notificationsHasMore || notifications.length === 0) return null;
const hasMore = notificationsHasMore || announcementsHasMore;
if (!hasMore || mergedItems.length === 0) return null;
if (loading.notifications) {
if (loading.notifications || loading.announcements) {
return (
<Center py={4}>
<Spinner size="sm" color="primary.500" />
@@ -300,11 +407,11 @@ const Notifications = ({ navigation }) => {
};
return (
<Box flex={1} bg="#0F172A">
<Box flex={1} bg="#0F172A" pt={canGoBack ? insets.top : 0}>
<FlatList
data={notifications || []}
renderItem={renderNotificationItem}
keyExtractor={(item, index) => item?.id || `notification-${index}`}
data={mergedItems}
renderItem={renderItem}
keyExtractor={(item, index) => item?.id || `item-${index}`}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}

View File

@@ -551,6 +551,98 @@ export const uploadService = {
},
};
/**
* 公告服务 - 获取和管理官方公告
*/
export const announcementService = {
/**
* 获取公告列表
* @param {object} options - 分页和筛选选项
* @param {number} options.page - 页码,默认 1
* @param {number} options.limit - 每页数量,默认 20
* @param {boolean} options.unreadOnly - 是否只显示未读,默认 false
* @returns {Promise<{data: Array, hasMore: boolean, unreadCount: number}>}
*/
getAnnouncements: async (options = {}) => {
try {
const { page = 1, limit = 20, unreadOnly = false } = options;
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
unread_only: String(unreadOnly),
});
const response = await fetch(`${API_BASE_URL}/api/social/announcements?${params}`, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
console.warn('[SocialService] getAnnouncements 失败,返回空列表');
return { data: [], hasMore: false, unreadCount: 0 };
}
const result = await response.json();
const data = result.data || {};
return {
data: data.list || [],
hasMore: data.hasMore || false,
unreadCount: data.unreadCount || 0,
};
} catch (error) {
console.warn('[SocialService] getAnnouncements 错误:', error);
return { data: [], hasMore: false, unreadCount: 0 };
}
},
/**
* 标记公告为已读
* @param {number} announcementId - 公告 ID
*/
markAsRead: async (announcementId) => {
try {
const response = await fetch(
`${API_BASE_URL}/api/social/announcements/${announcementId}/read`,
{
method: 'POST',
credentials: 'include',
}
);
if (!response.ok) {
throw new Error('标记已读失败');
}
} catch (error) {
console.warn('[SocialService] markAnnouncementAsRead 错误:', error);
throw error;
}
},
/**
* 标记所有公告为已读
*/
markAllAsRead: async () => {
try {
const response = await fetch(
`${API_BASE_URL}/api/social/announcements/read-all`,
{
method: 'POST',
credentials: 'include',
}
);
if (!response.ok) {
throw new Error('标记全部已读失败');
}
} catch (error) {
console.warn('[SocialService] markAllAnnouncementsAsRead 错误:', error);
throw error;
}
},
};
/**
* 通知服务 - 使用后端 API
*/
@@ -757,6 +849,7 @@ export default {
postService,
memberService,
notificationService,
announcementService,
uploadService,
CHANNEL_TYPES,
CHANNEL_CATEGORIES,

View File

@@ -10,6 +10,7 @@ import {
postService,
memberService,
notificationService,
announcementService,
} from '../../services/socialService';
// ============ Async Thunks ============
@@ -174,6 +175,50 @@ export const markAllNotificationsRead = createAsyncThunk(
}
);
// 获取公告列表
export const fetchAnnouncements = createAsyncThunk(
'social/fetchAnnouncements',
async (options = {}, { rejectWithValue }) => {
try {
const response = await announcementService.getAnnouncements(options);
return {
announcements: response.data || [],
hasMore: response.hasMore,
unreadCount: response.unreadCount || 0,
page: options.page || 1,
};
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 标记公告已读
export const markAnnouncementRead = createAsyncThunk(
'social/markAnnouncementRead',
async (announcementId, { rejectWithValue }) => {
try {
await announcementService.markAsRead(announcementId);
return announcementId;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 标记所有公告已读
export const markAllAnnouncementsRead = createAsyncThunk(
'social/markAllAnnouncementsRead',
async (_, { rejectWithValue }) => {
try {
await announcementService.markAllAsRead();
return true;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// ============ Initial State ============
const initialState = {
@@ -201,6 +246,11 @@ const initialState = {
notificationsHasMore: true,
unreadCount: 0,
// 公告相关
announcements: [],
announcementsHasMore: true,
announcementUnreadCount: 0,
// 加载状态
loading: {
channels: false,
@@ -210,6 +260,7 @@ const initialState = {
replies: false,
members: false,
notifications: false,
announcements: false,
sending: false,
},
@@ -318,6 +369,16 @@ const socialSlice = createSlice({
state.unreadCount += 1;
},
// 设置公告未读数量
setAnnouncementUnreadCount: (state, action) => {
state.announcementUnreadCount = action.payload;
},
// 增加公告未读数量
incrementAnnouncementUnreadCount: (state) => {
state.announcementUnreadCount += 1;
},
// 清除错误
clearError: (state) => {
state.error = null;
@@ -495,6 +556,47 @@ const socialSlice = createSlice({
n.isRead = true;
});
state.unreadCount = 0;
})
// 获取公告
.addCase(fetchAnnouncements.pending, (state) => {
state.loading.announcements = true;
})
.addCase(fetchAnnouncements.fulfilled, (state, action) => {
state.loading.announcements = false;
const { announcements, hasMore, unreadCount, page } = action.payload;
// 第一页替换,后续页追加
if (page === 1) {
state.announcements = announcements;
} else {
state.announcements = [...state.announcements, ...announcements];
}
state.announcementsHasMore = hasMore;
if (typeof unreadCount === 'number') {
state.announcementUnreadCount = unreadCount;
}
})
.addCase(fetchAnnouncements.rejected, (state, action) => {
state.loading.announcements = false;
state.error = action.payload;
})
// 标记公告已读
.addCase(markAnnouncementRead.fulfilled, (state, action) => {
const announcementId = action.payload;
const announcement = state.announcements.find(a => a.id === announcementId);
if (announcement && !announcement.isRead) {
announcement.isRead = true;
state.announcementUnreadCount = Math.max(0, state.announcementUnreadCount - 1);
}
})
// 标记所有公告已读
.addCase(markAllAnnouncementsRead.fulfilled, (state) => {
state.announcements.forEach(a => {
a.isRead = true;
});
state.announcementUnreadCount = 0;
});
},
});
@@ -513,6 +615,8 @@ export const {
removeTypingUser,
setUnreadCount,
incrementUnreadCount,
setAnnouncementUnreadCount,
incrementAnnouncementUnreadCount,
clearError,
resetSocialState,
} = socialSlice.actions;

View File

@@ -206,11 +206,12 @@ def send_social_notification(notification, device_tokens: List[str]) -> dict:
# 根据通知类型设置标题
type_labels = {
'announcement': '📣 官方公告',
'reply': '💬 新回复',
'mention': '📢 有人@你',
'like': '❤️ 收到点赞',
'follow': '👋 新关注',
'system': '📣 系统通知',
'system': '🔔 系统通知',
'message': '💬 新消息',
}
title = type_labels.get(notif_type, '📣 新通知')

407
app.py
View File

@@ -1619,6 +1619,64 @@ class SocialNotification(db.Model):
}
class Announcement(db.Model):
"""官方公告表"""
__tablename__ = 'announcements'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=True)
content = db.Column(db.Text, nullable=False)
target_url = db.Column(db.String(500), nullable=True) # 点击跳转链接
# 发布者信息
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# 状态
is_active = db.Column(db.Boolean, default=True, index=True) # 是否有效(可用于软删除)
is_pushed = db.Column(db.Boolean, default=False) # 是否已推送
# 时间
created_at = db.Column(db.DateTime, default=beijing_now, index=True)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# 统计
read_count = db.Column(db.Integer, default=0) # 已读人数
# 关系
creator = db.relationship('User', backref='created_announcements')
def to_dict(self, user_read_status=None):
return {
'id': self.id,
'title': self.title,
'content': self.content,
'targetUrl': self.target_url,
'createdBy': self.created_by,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'readCount': self.read_count,
'isRead': user_read_status, # 当前用户是否已读
}
class AnnouncementReadStatus(db.Model):
"""公告已读状态表"""
__tablename__ = 'announcement_read_status'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
announcement_id = db.Column(db.Integer, db.ForeignKey('announcements.id'), nullable=False, index=True)
read_at = db.Column(db.DateTime, default=beijing_now)
# 联合唯一索引
__table_args__ = (
db.UniqueConstraint('user_id', 'announcement_id', name='uix_user_announcement'),
)
# 关系
user = db.relationship('User', backref='announcement_reads')
announcement = db.relationship('Announcement', backref='read_statuses')
class SubscriptionPlan(db.Model):
"""订阅套餐表"""
__tablename__ = 'subscription_plans'
@@ -21547,6 +21605,355 @@ def send_social_notification_api():
return jsonify({'code': 500, 'message': str(e)}), 500
# ==================== 官方公告 API ====================
@app.route('/api/social/announcements', methods=['GET'])
@login_required
def get_announcements():
"""
获取公告列表(用户端)
返回所有有效公告及当前用户的已读状态
查询参数:
- page: 页码,默认 1
- limit: 每页数量,默认 20
- unread_only: 是否只看未读,默认 false
"""
try:
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 20, type=int), 50)
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
# 查询有效公告
query = db.session.query(Announcement).filter(
Announcement.is_active == True
)
# 如果只看未读,排除已读的公告
if unread_only:
read_announcement_ids = db.session.query(
AnnouncementReadStatus.announcement_id
).filter(
AnnouncementReadStatus.user_id == current_user.id
).subquery()
query = query.filter(~Announcement.id.in_(read_announcement_ids))
# 按创建时间倒序
query = query.order_by(Announcement.created_at.desc())
total = query.count()
announcements = query.offset((page - 1) * limit).limit(limit).all()
# 获取当前用户对这些公告的已读状态
announcement_ids = [a.id for a in announcements]
read_status_query = db.session.query(
AnnouncementReadStatus.announcement_id
).filter(
AnnouncementReadStatus.user_id == current_user.id,
AnnouncementReadStatus.announcement_id.in_(announcement_ids)
).all()
read_ids = {rs[0] for rs in read_status_query}
# 统计未读数量
unread_count = db.session.query(Announcement).filter(
Announcement.is_active == True,
~Announcement.id.in_(
db.session.query(AnnouncementReadStatus.announcement_id).filter(
AnnouncementReadStatus.user_id == current_user.id
)
)
).count()
return jsonify({
'code': 200,
'message': 'success',
'data': {
'list': [
a.to_dict(user_read_status=(a.id in read_ids))
for a in announcements
],
'total': total,
'hasMore': page * limit < total,
'unreadCount': unread_count
}
})
except Exception as e:
app.logger.error(f"获取公告列表失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/social/announcements', methods=['POST'])
@login_required
def publish_announcement():
"""
发布官方公告(管理员专用)
创建一条公告记录并推送给所有用户
请求体:
{
"title": "公告标题",
"content": "公告内容",
"target_url": "可选,点击跳转的链接",
"push_to_all": true // 是否推送给所有用户,默认 true
}
"""
try:
# 检查管理员权限user_id=1 或有 admin 标记)
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
return jsonify({'code': 403, 'message': '无权限发布公告'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'message': '缺少请求体'}), 400
title = data.get('title', '').strip()
content = data.get('content', '').strip()
target_url = data.get('target_url')
push_to_all = data.get('push_to_all', True)
if not title and not content:
return jsonify({'code': 400, 'message': '标题和内容不能都为空'}), 400
# 创建公告记录
announcement = Announcement(
title=title or None,
content=content,
target_url=target_url,
created_by=current_user.id,
is_active=True,
is_pushed=push_to_all,
)
db.session.add(announcement)
db.session.commit()
print(f'[公告] 已创建公告 ID={announcement.id}')
# 组合推送内容
notification_content = f"{title}{content}" if title else content
# 推送通知
push_result = {'success': 0, 'failed': 0}
if push_to_all:
try:
from apns_push import send_push_notification
# 获取所有活跃设备 token
device_tokens_query = db.session.query(UserDeviceToken.device_token).filter(
UserDeviceToken.is_active == True
).all()
tokens = [t[0] for t in device_tokens_query]
if tokens:
print(f'[公告 APNs] 准备推送到 {len(tokens)} 个设备')
push_result = send_push_notification(
device_tokens=tokens,
title='📣 官方公告',
body=notification_content[:100] + ('...' if len(notification_content) > 100 else ''),
data={
'type': 'announcement',
'announcement_id': announcement.id,
'title': title,
'target_url': target_url
},
badge=1,
priority=10
)
print(f'[公告 APNs] 推送结果: 成功 {push_result["success"]}, 失败 {push_result["failed"]}')
else:
print('[公告 APNs] 没有活跃的设备 token')
except ImportError as e:
print(f'[公告 APNs WARN] apns_push 模块未安装: {e}')
except Exception as apns_error:
print(f'[公告 APNs ERROR] 推送失败: {apns_error}')
return jsonify({
'code': 200,
'message': '公告已发布',
'data': {
'id': announcement.id,
'title': title,
'content': content,
'push_success': push_result.get('success', 0),
'push_failed': push_result.get('failed', 0)
}
})
except Exception as e:
db.session.rollback()
app.logger.error(f"发布公告失败: {e}")
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/social/announcements/<int:announcement_id>/read', methods=['POST'])
@login_required
def mark_announcement_read(announcement_id):
"""
标记公告为已读
"""
try:
# 检查公告是否存在
announcement = db.session.query(Announcement).filter(
Announcement.id == announcement_id,
Announcement.is_active == True
).first()
if not announcement:
return jsonify({'code': 404, 'message': '公告不存在'}), 404
# 检查是否已经标记过
existing = db.session.query(AnnouncementReadStatus).filter(
AnnouncementReadStatus.user_id == current_user.id,
AnnouncementReadStatus.announcement_id == announcement_id
).first()
if not existing:
# 创建已读记录
read_status = AnnouncementReadStatus(
user_id=current_user.id,
announcement_id=announcement_id
)
db.session.add(read_status)
# 更新公告已读计数
announcement.read_count = (announcement.read_count or 0) + 1
db.session.commit()
return jsonify({
'code': 200,
'message': '已标记为已读'
})
except Exception as e:
db.session.rollback()
app.logger.error(f"标记公告已读失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/social/announcements/read-all', methods=['POST'])
@login_required
def mark_all_announcements_read():
"""
标记所有公告为已读
"""
try:
# 获取用户尚未读取的公告
read_ids = db.session.query(AnnouncementReadStatus.announcement_id).filter(
AnnouncementReadStatus.user_id == current_user.id
).subquery()
unread_announcements = db.session.query(Announcement).filter(
Announcement.is_active == True,
~Announcement.id.in_(read_ids)
).all()
count = 0
for announcement in unread_announcements:
read_status = AnnouncementReadStatus(
user_id=current_user.id,
announcement_id=announcement.id
)
db.session.add(read_status)
announcement.read_count = (announcement.read_count or 0) + 1
count += 1
db.session.commit()
return jsonify({
'code': 200,
'message': f'已标记 {count} 条公告为已读',
'data': {'marked_count': count}
})
except Exception as e:
db.session.rollback()
app.logger.error(f"标记所有公告已读失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/social/announcements/history', methods=['GET'])
@login_required
def get_announcement_history():
"""
获取公告历史(管理员查看)
"""
try:
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
return jsonify({'code': 403, 'message': '无权限'}), 403
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
# 从 Announcement 表查询
query = db.session.query(Announcement).order_by(
Announcement.created_at.desc()
)
total = query.count()
announcements = query.offset((page - 1) * limit).limit(limit).all()
return jsonify({
'code': 200,
'message': 'success',
'data': {
'announcements': [
{
'id': a.id,
'title': a.title,
'content': a.content,
'target_url': a.target_url,
'created_at': a.created_at.isoformat() if a.created_at else None,
'read_count': a.read_count or 0,
'is_active': a.is_active,
'is_pushed': a.is_pushed
}
for a in announcements
],
'total': total,
'hasMore': page * limit < total
}
})
except Exception as e:
app.logger.error(f"获取公告历史失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
@app.route('/api/social/announcements/<int:announcement_id>', methods=['DELETE'])
@login_required
def delete_announcement(announcement_id):
"""
删除公告(软删除,管理员专用)
"""
try:
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
return jsonify({'code': 403, 'message': '无权限'}), 403
announcement = db.session.query(Announcement).filter(
Announcement.id == announcement_id
).first()
if not announcement:
return jsonify({'code': 404, 'message': '公告不存在'}), 404
# 软删除
announcement.is_active = False
db.session.commit()
return jsonify({
'code': 200,
'message': '公告已删除'
})
except Exception as e:
db.session.rollback()
app.logger.error(f"删除公告失败: {e}")
return jsonify({'code': 500, 'message': str(e)}), 500
if __name__ == '__main__':
# 创建数据库表
with app.app_context():